| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Mox do | |
| 1 | @moduledoc ~S""" | |
| 2 | Mox is a library for defining concurrent mocks in Elixir. | |
| 3 | ||
| 4 | The library follows the principles outlined in | |
| 5 | ["Mocks and explicit contracts"](https://dashbit.co/blog/mocks-and-explicit-contracts), | |
| 6 | summarized below: | |
| 7 | ||
| 8 | 1. No ad-hoc mocks. You can only create mocks based on behaviours | |
| 9 | ||
| 10 | 2. No dynamic generation of modules during tests. Mocks are preferably defined | |
| 11 | in your `test_helper.exs` or in a `setup_all` block and not per test | |
| 12 | ||
| 13 | 3. Concurrency support. Tests using the same mock can still use `async: true` | |
| 14 | ||
| 15 | 4. Rely on pattern matching and function clauses for asserting on the | |
| 16 | input instead of complex expectation rules | |
| 17 | ||
| 18 | ## Example | |
| 19 | ||
| 20 | Imagine that you have an app that has to display the weather. At first, | |
| 21 | you use an external API to give you the data given a lat/long pair: | |
| 22 | ||
| 23 | defmodule MyApp.HumanizedWeather do | |
| 24 | def display_temp({lat, long}) do | |
| 25 | {:ok, temp} = MyApp.Weather.temp({lat, long}) | |
| 26 | "Current temperature is #{temp} degrees" | |
| 27 | end | |
| 28 | ||
| 29 | def display_humidity({lat, long}) do | |
| 30 | {:ok, humidity} = MyApp.Weather.humidity({lat, long}) | |
| 31 | "Current humidity is #{humidity}%" | |
| 32 | end | |
| 33 | end | |
| 34 | ||
| 35 | However, you want to test the code above without performing external | |
| 36 | API calls. How to do so? | |
| 37 | ||
| 38 | First, it is important to define the `Weather` behaviour that we want | |
| 39 | to mock. And we will define a proxy functions that will dispatch to | |
| 40 | the desired implementation: | |
| 41 | ||
| 42 | defmodule MyApp.WeatherAPI do | |
| 43 | @callback temp(MyApp.LatLong.t()) :: {:ok, integer()} | |
| 44 | @callback humidity(MyApp.LatLong.t()) :: {:ok, integer()} | |
| 45 | ||
| 46 | def temp(lat_long), do: impl().temp(lat_long) | |
| 47 | def humidity(lat_long), do: impl().humidity(lat_long) | |
| 48 | defp impl, do: Application.get_env(:my_app, :weather, MyApp.ExternalWeatherAPI) | |
| 49 | end | |
| 50 | ||
| 51 | By default, we will dispatch to MyApp.ExternalWeatherAPI, which now contains | |
| 52 | the external API implementation. | |
| 53 | ||
| 54 | If you want to mock the WeatherAPI behaviour during tests, the first step | |
| 55 | is to define the mock with `defmock/2`, usually in your `test_helper.exs`, | |
| 56 | and configure your application to use it: | |
| 57 | ||
| 58 | Mox.defmock(MyApp.MockWeatherAPI, for: MyApp.Weather) | |
| 59 | Application.put_env(:my_app, :weather, MyApp.MockWeatherAPI) | |
| 60 | ||
| 61 | Now in your tests, you can define expectations with `expect/4` and verify | |
| 62 | them via `verify_on_exit!/1`: | |
| 63 | ||
| 64 | defmodule MyApp.HumanizedWeatherTest do | |
| 65 | use ExUnit.Case, async: true | |
| 66 | ||
| 67 | import Mox | |
| 68 | ||
| 69 | # Make sure mocks are verified when the test exits | |
| 70 | setup :verify_on_exit! | |
| 71 | ||
| 72 | test "gets and formats temperature and humidity" do | |
| 73 | MyApp.MockWeatherAPI | |
| 74 | |> expect(:temp, fn {_lat, _long} -> {:ok, 30} end) | |
| 75 | |> expect(:humidity, fn {_lat, _long} -> {:ok, 60} end) | |
| 76 | ||
| 77 | assert MyApp.HumanizedWeather.display_temp({50.06, 19.94}) == | |
| 78 | "Current temperature is 30 degrees" | |
| 79 | ||
| 80 | assert MyApp.HumanizedWeather.display_humidity({50.06, 19.94}) == | |
| 81 | "Current humidity is 60%" | |
| 82 | end | |
| 83 | end | |
| 84 | ||
| 85 | All expectations are defined based on the current process. This | |
| 86 | means multiple tests using the same mock can still run concurrently | |
| 87 | unless the Mox is set to global mode. See the "Multi-process collaboration" | |
| 88 | section. | |
| 89 | ||
| 90 | One last note, if the mock is used throughout the test suite, you might want | |
| 91 | the implementation to fall back to a stub (or actual) implementation when no | |
| 92 | expectations are defined. You can use `stub_with/2` in your `test_helper.exs` | |
| 93 | as follows: | |
| 94 | ||
| 95 | Mox.stub_with(MyApp.MockWeatherAPI, MyApp.StubWeatherAPI) | |
| 96 | ||
| 97 | Now, if no expectations are defined it will call the implementation in | |
| 98 | `MyApp.StubWeatherAPI`. | |
| 99 | ||
| 100 | ## Multiple behaviours | |
| 101 | ||
| 102 | Mox supports defining mocks for multiple behaviours. | |
| 103 | ||
| 104 | Suppose your library also defines a behaviour for getting past weather: | |
| 105 | ||
| 106 | defmodule MyApp.PastWeather do | |
| 107 | @callback past_temp(MyApp.LatLong.t(), DateTime.t()) :: {:ok, integer()} | |
| 108 | end | |
| 109 | ||
| 110 | You can mock both the weather and past weather behaviour: | |
| 111 | ||
| 112 | Mox.defmock(MyApp.MockWeatherAPI, for: [MyApp.Weather, MyApp.PastWeather]) | |
| 113 | ||
| 114 | ## Compile-time requirements | |
| 115 | ||
| 116 | If the mock needs to be available during the project compilation, for | |
| 117 | instance because you get undefined function warnings, then instead of | |
| 118 | defining the mock in your `test_helper.exs`, you should instead define | |
| 119 | it under `test/support/mocks.ex`: | |
| 120 | ||
| 121 | Mox.defmock(MyApp.MockWeatherAPI, for: MyApp.WeatherAPI) | |
| 122 | ||
| 123 | Then you need to make sure that files in `test/support` get compiled | |
| 124 | with the rest of the project. Edit your `mix.exs` file to add the | |
| 125 | `test/support` directory to compilation paths: | |
| 126 | ||
| 127 | def project do | |
| 128 | [ | |
| 129 | ... | |
| 130 | elixirc_paths: elixirc_paths(Mix.env), | |
| 131 | ... | |
| 132 | ] | |
| 133 | end | |
| 134 | ||
| 135 | defp elixirc_paths(:test), do: ["test/support", "lib"] | |
| 136 | defp elixirc_paths(_), do: ["lib"] | |
| 137 | ||
| 138 | ## Multi-process collaboration | |
| 139 | ||
| 140 | Mox supports multi-process collaboration via two mechanisms: | |
| 141 | ||
| 142 | 1. explicit allowances | |
| 143 | 2. global mode | |
| 144 | ||
| 145 | The allowance mechanism can still run tests concurrently while | |
| 146 | the global one doesn't. We explore both next. | |
| 147 | ||
| 148 | ### Explicit allowances | |
| 149 | ||
| 150 | An allowance permits a child process to use the expectations and stubs | |
| 151 | defined in the parent process while still being safe for async tests. | |
| 152 | ||
| 153 | test "invokes add and mult from a task" do | |
| 154 | MyApp.MockWeatherAPI | |
| 155 | |> expect(:temp, fn _loc -> {:ok, 30} end) | |
| 156 | |> expect(:humidity, fn _loc -> {:ok, 60} end) | |
| 157 | ||
| 158 | parent_pid = self() | |
| 159 | ||
| 160 | Task.async(fn -> | |
| 161 | MyApp.MockWeatherAPI |> allow(parent_pid, self()) | |
| 162 | ||
| 163 | assert MyApp.HumanizedWeather.display_temp({50.06, 19.94}) == | |
| 164 | "Current temperature is 30 degrees" | |
| 165 | ||
| 166 | assert MyApp.HumanizedWeather.display_humidity({50.06, 19.94}) == | |
| 167 | "Current humidity is 60%" | |
| 168 | end) | |
| 169 | |> Task.await | |
| 170 | end | |
| 171 | ||
| 172 | Note: if you're running on Elixir 1.8.0 or greater and your concurrency comes | |
| 173 | from a `Task` then you don't need to add explicit allowances. Instead | |
| 174 | `$callers` is used to determine the process that actually defined the | |
| 175 | expectations. | |
| 176 | ||
| 177 | ### Global mode | |
| 178 | ||
| 179 | Mox supports global mode, where any process can consume mocks and stubs | |
| 180 | defined in your tests. `set_mox_from_context/0` automatically calls | |
| 181 | `set_mox_global/1` but only if the test context **doesn't** include | |
| 182 | `async: true`. | |
| 183 | ||
| 184 | By default the mode is `:private`. | |
| 185 | ||
| 186 | setup :set_mox_from_context | |
| 187 | setup :verify_on_exit! | |
| 188 | ||
| 189 | test "invokes add and mult from a task" do | |
| 190 | MyApp.MockWeatherAPI | |
| 191 | |> expect(:temp, fn _loc -> {:ok, 30} end) | |
| 192 | |> expect(:humidity, fn _loc -> {:ok, 60} end) | |
| 193 | ||
| 194 | Task.async(fn -> | |
| 195 | assert MyApp.HumanizedWeather.display_temp({50.06, 19.94}) == | |
| 196 | "Current temperature is 30 degrees" | |
| 197 | ||
| 198 | assert MyApp.HumanizedWeather.display_humidity({50.06, 19.94}) == | |
| 199 | "Current humidity is 60%" | |
| 200 | end) | |
| 201 | |> Task.await | |
| 202 | end | |
| 203 | ||
| 204 | ### Blocking on expectations | |
| 205 | ||
| 206 | If your mock is called in a different process than the test process, | |
| 207 | in some cases there is a chance that the test will finish executing | |
| 208 | before it has a chance to call the mock and meet the expectations. | |
| 209 | Imagine this: | |
| 210 | ||
| 211 | test "calling a mock from a different process" do | |
| 212 | expect(MyApp.MockWeatherAPI, :temp, fn _loc -> {:ok, 30} end) | |
| 213 | ||
| 214 | spawn(fn -> MyApp.HumanizedWeather.temp({50.06, 19.94}) end) | |
| 215 | ||
| 216 | verify!() | |
| 217 | end | |
| 218 | ||
| 219 | The test above has a race condition because there is a chance that the | |
| 220 | `verify!/0` call will happen before the spawned process calls the mock. | |
| 221 | In most cases, you don't control the spawning of the process so you can't | |
| 222 | simply monitor the process to know when it dies in order to avoid this | |
| 223 | race condition. In those cases, the way to go is to "sync up" with the | |
| 224 | process that calls the mock by sending a message to the test process | |
| 225 | from the expectation and using that to know when the expectation has been | |
| 226 | called. | |
| 227 | ||
| 228 | test "calling a mock from a different process" do | |
| 229 | parent = self() | |
| 230 | ref = make_ref() | |
| 231 | ||
| 232 | expect(MyApp.MockWeatherAPI, :temp, fn _loc -> | |
| 233 | send(parent, {ref, :temp}) | |
| 234 | {:ok, 30} | |
| 235 | end) | |
| 236 | ||
| 237 | spawn(fn -> MyApp.HumanizedWeather.temp({50.06, 19.94}) end) | |
| 238 | ||
| 239 | assert_receive {^ref, :temp} | |
| 240 | ||
| 241 | verify!() | |
| 242 | end | |
| 243 | ||
| 244 | This way, we'll wait until the expectation is called before calling | |
| 245 | `verify!/0`. | |
| 246 | """ | |
| 247 | ||
| 248 | defmodule UnexpectedCallError do | |
| 249 | defexception [:message] | |
| 250 | end | |
| 251 | ||
| 252 | defmodule VerificationError do | |
| 253 | defexception [:message] | |
| 254 | end | |
| 255 | ||
| 256 | @doc """ | |
| 257 | Sets the Mox to private mode. | |
| 258 | ||
| 259 | In private mode, mocks can be set and consumed by the same | |
| 260 | process unless other processes are explicitly allowed. | |
| 261 | ||
| 262 | ## Examples | |
| 263 | ||
| 264 | setup :set_mox_private | |
| 265 | ||
| 266 | """ | |
| 267 | def set_mox_private(_context \\ %{}), do: Mox.Server.set_mode(self(), :private) | |
| 268 | ||
| 269 | @doc """ | |
| 270 | Sets the Mox to global mode. | |
| 271 | ||
| 272 | In global mode, mocks can be consumed by any process. | |
| 273 | ||
| 274 | An ExUnit case where tests use Mox in global mode cannot be | |
| 275 | `async: true`. | |
| 276 | ||
| 277 | ## Examples | |
| 278 | ||
| 279 | setup :set_mox_global | |
| 280 | """ | |
| 281 | def set_mox_global(context \\ %{}) do | |
| 282 | if Map.get(context, :async) do | |
| 283 | raise "Mox cannot be set to global mode when the ExUnit case is async. " <> | |
| 284 | "If you want to use Mox in global mode, remove \"async: true\" when using ExUnit.Case" | |
| 285 | else | |
| 286 | Mox.Server.set_mode(self(), :global) | |
| 287 | end | |
| 288 | end | |
| 289 | ||
| 290 | @doc """ | |
| 291 | Chooses the Mox mode based on context. | |
| 292 | ||
| 293 | When `async: true` is used, `set_mox_private/1` is called, | |
| 294 | otherwise `set_mox_global/1` is used. | |
| 295 | ||
| 296 | ## Examples | |
| 297 | ||
| 298 | setup :set_mox_from_context | |
| 299 | ||
| 300 | """ | |
| 301 | def set_mox_from_context(%{async: true} = _context), do: set_mox_private() | |
| 302 | def set_mox_from_context(_context), do: set_mox_global() | |
| 303 | ||
| 304 | @doc """ | |
| 305 | Defines a mock with the given name `:for` the given behaviour(s). | |
| 306 | ||
| 307 | Mox.defmock(MyMock, for: MyBehaviour) | |
| 308 | ||
| 309 | With multiple behaviours: | |
| 310 | ||
| 311 | Mox.defmock(MyMock, for: [MyBehaviour, MyOtherBehaviour]) | |
| 312 | ||
| 313 | ## Skipping optional callbacks | |
| 314 | ||
| 315 | By default, functions are created for all the behaviour's callbacks, | |
| 316 | including optional ones. But if for some reason you want to skip one or more | |
| 317 | of its `@optional_callbacks`, you can provide the list of callback names to | |
| 318 | skip (along with their arities) as `:skip_optional_callbacks`: | |
| 319 | ||
| 320 | Mox.defmock(MyMock, for: MyBehaviour, skip_optional_callbacks: [on_success: 2]) | |
| 321 | ||
| 322 | This will define a new mock (`MyMock`) that has a defined function for each | |
| 323 | callback on `MyBehaviour` except for `on_success/2`. Note: you can only skip | |
| 324 | optional callbacks, not required callbacks. | |
| 325 | ||
| 326 | You can also pass `true` to skip all optional callbacks, or `false` to keep | |
| 327 | the default of generating functions for all optional callbacks. | |
| 328 | ||
| 329 | ## Passing `@moduledoc` | |
| 330 | ||
| 331 | You can provide value for `@moduledoc` with `:moduledoc` option. | |
| 332 | ||
| 333 | Mox.defmock(MyMock, for: MyBehaviour, moduledoc: false) | |
| 334 | Mox.defmock(MyMock, for: MyBehaviour, moduledoc: "My mock module.") | |
| 335 | ||
| 336 | """ | |
| 337 | def defmock(name, options) when is_atom(name) and is_list(options) do | |
| 338 | behaviours = | |
| 339 | case Keyword.fetch(options, :for) do | |
| 340 | {:ok, mocks} -> List.wrap(mocks) | |
| 341 | :error -> raise ArgumentError, ":for option is required on defmock" | |
| 342 | end | |
| 343 | ||
| 344 | skip_optional_callbacks = Keyword.get(options, :skip_optional_callbacks, []) | |
| 345 | moduledoc = Keyword.get(options, :moduledoc, false) | |
| 346 | ||
| 347 | doc_header = generate_doc_header(moduledoc) | |
| 348 | compile_header = generate_compile_time_dependency(behaviours) | |
| 349 | callbacks_to_skip = validate_skip_optional_callbacks!(behaviours, skip_optional_callbacks) | |
| 350 | mock_funs = generate_mock_funs(behaviours, callbacks_to_skip) | |
| 351 | ||
| 352 | define_mock_module(name, behaviours, doc_header ++ compile_header ++ mock_funs) | |
| 353 | ||
| 354 | name | |
| 355 | end | |
| 356 | ||
| 357 | defp validate_behaviour!(behaviour) do | |
| 358 | cond do | |
| 359 | Code.ensure_compiled(behaviour) != {:module, behaviour} -> | |
| 360 | raise ArgumentError, | |
| 361 | "module #{inspect(behaviour)} is not available, please pass an existing module to :for" | |
| 362 | ||
| 363 | not function_exported?(behaviour, :behaviour_info, 1) -> | |
| 364 | raise ArgumentError, | |
| 365 | "module #{inspect(behaviour)} is not a behaviour, please pass a behaviour to :for" | |
| 366 | ||
| 367 | true -> | |
| 368 | behaviour | |
| 369 | end | |
| 370 | end | |
| 371 | ||
| 372 | defp generate_doc_header(moduledoc) do | |
| 373 | [ | |
| 374 | quote do | |
| 375 | @moduledoc unquote(moduledoc) | |
| 376 | end | |
| 377 | ] | |
| 378 | end | |
| 379 | ||
| 380 | defp generate_compile_time_dependency(behaviours) do | |
| 381 | for behaviour <- behaviours do | |
| 382 | validate_behaviour!(behaviour) | |
| 383 | ||
| 384 | quote do | |
| 385 | @behaviour unquote(behaviour) | |
| 386 | unquote(behaviour).module_info(:module) | |
| 387 | end | |
| 388 | end | |
| 389 | end | |
| 390 | ||
| 391 | defp generate_mock_funs(behaviours, callbacks_to_skip) do | |
| 392 | for behaviour <- behaviours, | |
| 393 | {fun, arity} <- behaviour.behaviour_info(:callbacks), | |
| 394 | {fun, arity} not in callbacks_to_skip do | |
| 395 | args = 0..arity |> Enum.to_list() |> tl() |> Enum.map(&Macro.var(:"arg#{&1}", Elixir)) | |
| 396 | ||
| 397 | quote do | |
| 398 | def unquote(fun)(unquote_splicing(args)) do | |
| 399 | Mox.__dispatch__(__MODULE__, unquote(fun), unquote(arity), unquote(args)) | |
| 400 | end | |
| 401 | end | |
| 402 | end | |
| 403 | end | |
| 404 | ||
| 405 | defp validate_skip_optional_callbacks!(behaviours, skip_optional_callbacks) do | |
| 406 | all_optional_callbacks = | |
| 407 | for behaviour <- behaviours, | |
| 408 | {fun, arity} <- behaviour.behaviour_info(:optional_callbacks) do | |
| 409 | {fun, arity} | |
| 410 | end | |
| 411 | ||
| 412 | case skip_optional_callbacks do | |
| 413 | false -> | |
| 414 | [] | |
| 415 | ||
| 416 | true -> | |
| 417 | all_optional_callbacks | |
| 418 | ||
| 419 | skip_list when is_list(skip_list) -> | |
| 420 | for callback <- skip_optional_callbacks, callback not in all_optional_callbacks do | |
| 421 | raise ArgumentError, | |
| 422 | "all entries in :skip_optional_callbacks must be an optional callback in one " <> | |
| 423 | "of the behaviours specified in :for. #{inspect(callback)} was not in the " <> | |
| 424 | "list of all optional callbacks: #{inspect(all_optional_callbacks)}" | |
| 425 | end | |
| 426 | ||
| 427 | skip_list | |
| 428 | ||
| 429 | _ -> | |
| 430 | raise ArgumentError, ":skip_optional_callbacks is required to be a list or boolean" | |
| 431 | end | |
| 432 | end | |
| 433 | ||
| 434 | defp define_mock_module(name, behaviours, body) do | |
| 435 | info = | |
| 436 | quote do | |
| 437 | def __mock_for__ do | |
| 438 | unquote(behaviours) | |
| 439 | end | |
| 440 | end | |
| 441 | ||
| 442 | 22 | Module.create(name, [info | body], Macro.Env.location(__ENV__)) |
| 443 | end | |
| 444 | ||
| 445 | @doc """ | |
| 446 | Expects the `name` in `mock` with arity given by `code` | |
| 447 | to be invoked `n` times. | |
| 448 | ||
| 449 | If you're calling your mock from an asynchronous process and want | |
| 450 | to wait for the mock to be called, see the "Blocking on expectations" | |
| 451 | section in the module documentation. | |
| 452 | ||
| 453 | When `expect/4` is invoked, any previously declared `stub` for the same `name` and arity will | |
| 454 | be removed. This ensures that `expect` will fail if the function is called more than `n` times. | |
| 455 | If a `stub/3` is invoked **after** `expect/4` for the same `name` and arity, the stub will be | |
| 456 | used after all expectations are fulfilled. | |
| 457 | ||
| 458 | ## Examples | |
| 459 | ||
| 460 | To expect `MockWeatherAPI.get_temp/1` to be called once: | |
| 461 | ||
| 462 | expect(MockWeatherAPI, :get_temp, fn _ -> {:ok, 30} end) | |
| 463 | ||
| 464 | To expect `MockWeatherAPI.get_temp/1` to be called five times: | |
| 465 | ||
| 466 | expect(MockWeatherAPI, :get_temp, 5, fn _ -> {:ok, 30} end) | |
| 467 | ||
| 468 | To expect `MockWeatherAPI.get_temp/1` not to be called: | |
| 469 | ||
| 470 | expect(MockWeatherAPI, :get_temp, 0, fn _ -> {:ok, 30} end) | |
| 471 | ||
| 472 | `expect/4` can also be invoked multiple times for the same name/arity, | |
| 473 | allowing you to give different behaviours on each invocation. For instance, | |
| 474 | you could test that your code will try an API call three times before giving | |
| 475 | up: | |
| 476 | ||
| 477 | MockWeatherAPI | |
| 478 | |> expect(:get_temp, 2, fn _loc -> {:error, :unreachable} end) | |
| 479 | |> expect(:get_temp, 1, fn _loc -> {:ok, 30} end) | |
| 480 | ||
| 481 | log = capture_log(fn -> | |
| 482 | assert Weather.current_temp(location) | |
| 483 | == "It's currently 30 degrees" | |
| 484 | end) | |
| 485 | ||
| 486 | assert log =~ "attempt 1 failed" | |
| 487 | assert log =~ "attempt 2 failed" | |
| 488 | assert log =~ "attempt 3 succeeded" | |
| 489 | ||
| 490 | MockWeatherAPI | |
| 491 | |> expect(:get_temp, 3, fn _loc -> {:error, :unreachable} end) | |
| 492 | ||
| 493 | assert Weather.current_temp(location) == "Current temperature is unavailable" | |
| 494 | """ | |
| 495 | def expect(mock, name, n \\ 1, code) | |
| 496 | when is_atom(mock) and is_atom(name) and is_integer(n) and n >= 0 and is_function(code) do | |
| 497 | calls = List.duplicate(code, n) | |
| 498 | add_expectation!(mock, name, code, {n, calls, nil}) | |
| 499 | mock | |
| 500 | end | |
| 501 | ||
| 502 | @doc """ | |
| 503 | Allows the `name` in `mock` with arity given by `code` to | |
| 504 | be invoked zero or many times. | |
| 505 | ||
| 506 | Unlike expectations, stubs are never verified. | |
| 507 | ||
| 508 | If expectations and stubs are defined for the same function | |
| 509 | and arity, the stub is invoked only after all expectations are | |
| 510 | fulfilled. | |
| 511 | ||
| 512 | ## Examples | |
| 513 | ||
| 514 | To allow `MockWeatherAPI.get_temp/1` to be called any number of times: | |
| 515 | ||
| 516 | stub(MockWeatherAPI, :get_temp, fn _loc -> {:ok, 30} end) | |
| 517 | ||
| 518 | `stub/3` will overwrite any previous calls to `stub/3`. | |
| 519 | """ | |
| 520 | def stub(mock, name, code) | |
| 521 | when is_atom(mock) and is_atom(name) and is_function(code) do | |
| 522 | add_expectation!(mock, name, code, {0, [], code}) | |
| 523 | mock | |
| 524 | end | |
| 525 | ||
| 526 | @doc """ | |
| 527 | Stubs all functions described by the shared behaviours in the `mock` and `module`. | |
| 528 | ||
| 529 | ## Examples | |
| 530 | ||
| 531 | defmodule MyApp.WeatherAPI do | |
| 532 | @callback temp(MyApp.LatLong.t()) :: {:ok, integer()} | |
| 533 | @callback humidity(MyApp.LatLong.t()) :: {:ok, integer()} | |
| 534 | end | |
| 535 | ||
| 536 | defmodule MyApp.StubWeatherAPI do | |
| 537 | @behaviour WeatherAPI | |
| 538 | def temp(_loc), do: {:ok, 30} | |
| 539 | def humidity(_loc), do: {:ok, 60} | |
| 540 | end | |
| 541 | ||
| 542 | defmock(MyApp.MockWeatherAPI, for: MyApp.WeatherAPI) | |
| 543 | stub_with(MyApp.MockWeatherAPI, MyApp.StubWeatherAPI) | |
| 544 | ||
| 545 | This is the same as calling `stub/3` for each callback in `MyApp.MockWeatherAPI`: | |
| 546 | ||
| 547 | stub(MyApp.MockWeatherAPI, :temp, &MyApp.StubWeatherAPI.temp/1) | |
| 548 | stub(MyApp.MockWeatherAPI, :humidity, &MyApp.StubWeatherAPI.humidity/1) | |
| 549 | ||
| 550 | """ | |
| 551 | def stub_with(mock, module) when is_atom(mock) and is_atom(module) do | |
| 552 | mock_behaviours = mock.__mock_for__() | |
| 553 | ||
| 554 | behaviours = | |
| 555 | case module_behaviours(module) do | |
| 556 | [] -> | |
| 557 | raise ArgumentError, "#{inspect(module)} does not implement any behaviour" | |
| 558 | ||
| 559 | behaviours -> | |
| 560 | case Enum.filter(behaviours, &(&1 in mock_behaviours)) do | |
| 561 | [] -> | |
| 562 | raise ArgumentError, | |
| 563 | "#{inspect(module)} and #{inspect(mock)} do not share any behaviour" | |
| 564 | ||
| 565 | common -> | |
| 566 | common | |
| 567 | end | |
| 568 | end | |
| 569 | ||
| 570 | for behaviour <- behaviours, | |
| 571 | {fun, arity} <- behaviour.behaviour_info(:callbacks), | |
| 572 | function_exported?(mock, fun, arity) do | |
| 573 | stub(mock, fun, :erlang.make_fun(module, fun, arity)) | |
| 574 | end | |
| 575 | ||
| 576 | mock | |
| 577 | end | |
| 578 | ||
| 579 | defp module_behaviours(module) do | |
| 580 | module.module_info(:attributes) | |
| 581 | |> Keyword.get_values(:behaviour) | |
| 582 | |> List.flatten() | |
| 583 | end | |
| 584 | ||
| 585 | defp add_expectation!(mock, name, code, value) do | |
| 586 | validate_mock!(mock) | |
| 587 | arity = :erlang.fun_info(code)[:arity] | |
| 588 | key = {mock, name, arity} | |
| 589 | ||
| 590 | unless function_exported?(mock, name, arity) do | |
| 591 | raise ArgumentError, "unknown function #{name}/#{arity} for mock #{inspect(mock)}" | |
| 592 | end | |
| 593 | ||
| 594 | case Mox.Server.add_expectation(self(), key, value) do | |
| 595 | :ok -> | |
| 596 | :ok | |
| 597 | ||
| 598 | {:error, {:currently_allowed, owner_pid}} -> | |
| 599 | inspected = inspect(self()) | |
| 600 | ||
| 601 | raise ArgumentError, """ | |
| 602 | cannot add expectations/stubs to #{inspect(mock)} in the current process (#{inspected}) \ | |
| 603 | because the process has been allowed by #{inspect(owner_pid)}. \ | |
| 604 | You cannot define expectations/stubs in a process that has been allowed | |
| 605 | """ | |
| 606 | ||
| 607 | {:error, {:not_global_owner, global_pid}} -> | |
| 608 | inspected = inspect(self()) | |
| 609 | ||
| 610 | raise ArgumentError, """ | |
| 611 | cannot add expectations/stubs to #{inspect(mock)} in the current process (#{inspected}) \ | |
| 612 | because Mox is in global mode and the global process is #{inspect(global_pid)}. \ | |
| 613 | Only the process that set Mox to global can set expectations/stubs in global mode | |
| 614 | """ | |
| 615 | end | |
| 616 | end | |
| 617 | ||
| 618 | @doc """ | |
| 619 | Allows other processes to share expectations and stubs | |
| 620 | defined by owner process. | |
| 621 | ||
| 622 | ## Examples | |
| 623 | ||
| 624 | To allow `child_pid` to call any stubs or expectations defined for `MyMock`: | |
| 625 | ||
| 626 | allow(MyMock, self(), child_pid) | |
| 627 | ||
| 628 | `allow/3` also accepts named process or via references: | |
| 629 | ||
| 630 | allow(MyMock, self(), SomeChildProcess) | |
| 631 | ||
| 632 | """ | |
| 633 | def allow(mock, owner_pid, allowed_via) when is_atom(mock) and is_pid(owner_pid) do | |
| 634 | allowed_pid = GenServer.whereis(allowed_via) | |
| 635 | ||
| 636 | if allowed_pid == owner_pid do | |
| 637 | raise ArgumentError, "owner_pid and allowed_pid must be different" | |
| 638 | end | |
| 639 | ||
| 640 | case Mox.Server.allow(mock, owner_pid, allowed_pid) do | |
| 641 | :ok -> | |
| 642 | mock | |
| 643 | ||
| 644 | {:error, {:already_allowed, actual_pid}} -> | |
| 645 | raise ArgumentError, """ | |
| 646 | cannot allow #{inspect(allowed_pid)} to use #{inspect(mock)} from #{inspect(owner_pid)} \ | |
| 647 | because it is already allowed by #{inspect(actual_pid)}. | |
| 648 | ||
| 649 | If you are seeing this error message, it is because you are either \ | |
| 650 | setting up allowances from different processes or your tests have \ | |
| 651 | async: true and you found a race condition where two different tests \ | |
| 652 | are allowing the same process | |
| 653 | """ | |
| 654 | ||
| 655 | {:error, :expectations_defined} -> | |
| 656 | raise ArgumentError, """ | |
| 657 | cannot allow #{inspect(allowed_pid)} to use #{inspect(mock)} from #{inspect(owner_pid)} \ | |
| 658 | because the process has already defined its own expectations/stubs | |
| 659 | """ | |
| 660 | ||
| 661 | {:error, :in_global_mode} -> | |
| 662 | # Already allowed | |
| 663 | mock | |
| 664 | end | |
| 665 | end | |
| 666 | ||
| 667 | @doc """ | |
| 668 | Verifies the current process after it exits. | |
| 669 | ||
| 670 | If you want to verify expectations for all tests, you can use | |
| 671 | `verify_on_exit!/1` as a setup callback: | |
| 672 | ||
| 673 | setup :verify_on_exit! | |
| 674 | ||
| 675 | """ | |
| 676 | def verify_on_exit!(_context \\ %{}) do | |
| 677 | pid = self() | |
| 678 | Mox.Server.verify_on_exit(pid) | |
| 679 | ||
| 680 | ExUnit.Callbacks.on_exit(Mox, fn -> | |
| 681 | verify_mock_or_all!(pid, :all, :on_exit) | |
| 682 | end) | |
| 683 | end | |
| 684 | ||
| 685 | @doc """ | |
| 686 | Verifies that all expectations set by the current process | |
| 687 | have been called. | |
| 688 | """ | |
| 689 | def verify! do | |
| 690 | verify_mock_or_all!(self(), :all, :test) | |
| 691 | end | |
| 692 | ||
| 693 | @doc """ | |
| 694 | Verifies that all expectations in `mock` have been called. | |
| 695 | """ | |
| 696 | def verify!(mock) do | |
| 697 | validate_mock!(mock) | |
| 698 | verify_mock_or_all!(self(), mock, :test) | |
| 699 | end | |
| 700 | ||
| 701 | defp verify_mock_or_all!(pid, mock, test_or_on_exit) do | |
| 702 | pending = Mox.Server.verify(pid, mock, test_or_on_exit) | |
| 703 | ||
| 704 | messages = | |
| 705 | for {{module, name, arity}, total, pending} <- pending do | |
| 706 | mfa = Exception.format_mfa(module, name, arity) | |
| 707 | called = total - pending | |
| 708 | " * expected #{mfa} to be invoked #{times(total)} but it was invoked #{times(called)}" | |
| 709 | end | |
| 710 | ||
| 711 | if messages != [] do | |
| 712 | raise VerificationError, | |
| 713 | "error while verifying mocks for #{inspect(pid)}:\n\n" <> Enum.join(messages, "\n") | |
| 714 | end | |
| 715 | ||
| 716 | :ok | |
| 717 | end | |
| 718 | ||
| 719 | defp validate_mock!(mock) do | |
| 720 | cond do | |
| 721 | Code.ensure_compiled(mock) != {:module, mock} -> | |
| 722 | raise ArgumentError, "module #{inspect(mock)} is not available" | |
| 723 | ||
| 724 | not function_exported?(mock, :__mock_for__, 0) -> | |
| 725 | raise ArgumentError, "module #{inspect(mock)} is not a mock" | |
| 726 | ||
| 727 | true -> | |
| 728 | :ok | |
| 729 | end | |
| 730 | end | |
| 731 | ||
| 732 | @doc false | |
| 733 | def __dispatch__(mock, name, arity, args) do | |
| 734 | all_callers = [self() | caller_pids()] | |
| 735 | ||
| 736 | case Mox.Server.fetch_fun_to_dispatch(all_callers, {mock, name, arity}) do | |
| 737 | :no_expectation -> | |
| 738 | mfa = Exception.format_mfa(mock, name, arity) | |
| 739 | ||
| 740 | raise UnexpectedCallError, | |
| 741 | "no expectation defined for #{mfa} in #{format_process()} with args #{inspect(args)}" | |
| 742 | ||
| 743 | {:out_of_expectations, count} -> | |
| 744 | mfa = Exception.format_mfa(mock, name, arity) | |
| 745 | ||
| 746 | raise UnexpectedCallError, | |
| 747 | "expected #{mfa} to be called #{times(count)} but it has been " <> | |
| 748 | "called #{times(count + 1)} in #{format_process()}" | |
| 749 | ||
| 750 | {:ok, fun_to_call} -> | |
| 751 | apply(fun_to_call, args) | |
| 752 | end | |
| 753 | end | |
| 754 | ||
| 755 | defp times(1), do: "once" | |
| 756 | defp times(n), do: "#{n} times" | |
| 757 | ||
| 758 | defp format_process do | |
| 759 | callers = caller_pids() | |
| 760 | ||
| 761 | "process #{inspect(self())}" <> | |
| 762 | if Enum.empty?(callers) do | |
| 763 | "" | |
| 764 | else | |
| 765 | " (or in its callers #{inspect(callers)})" | |
| 766 | end | |
| 767 | end | |
| 768 | ||
| 769 | # Find the pid of the actual caller | |
| 770 | defp caller_pids do | |
| 771 | case Process.get(:"$callers") do | |
| 772 | nil -> [] | |
| 773 | pids when is_list(pids) -> pids | |
| 774 | end | |
| 775 | end | |
| 776 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Accounts.AuditLog do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | 1730 | schema "audit_logs" do |
| 4 | field :user_agent, :string | |
| 5 | field :remote_ip, :string | |
| 6 | field :action, :string | |
| 7 | field :params, :map | |
| 8 | ||
| 9 | belongs_to :user, User | |
| 10 | belongs_to :organization, Organization | |
| 11 | ||
| 12 | timestamps(updated_at: false) | |
| 13 | end | |
| 14 | ||
| 15 | def build(nil, user_agent, remote_ip, action, params) | |
| 16 | when action in ~w(password.reset.init password.reset.finish) do | |
| 17 | 6 | params = extract_params(action, params) |
| 18 | ||
| 19 | 6 | %AuditLog{ |
| 20 | user_id: nil, | |
| 21 | organization_id: nil, | |
| 22 | user_agent: user_agent, | |
| 23 | remote_ip: remote_ip, | |
| 24 | action: action, | |
| 25 | params: params | |
| 26 | } | |
| 27 | end | |
| 28 | ||
| 29 | def build(%User{id: user_id}, user_agent, remote_ip, "organization.create", organization) do | |
| 30 | 2 | params = extract_params("organization.create", organization) |
| 31 | ||
| 32 | 2 | %AuditLog{ |
| 33 | user_id: user_id, | |
| 34 | 2 | organization_id: organization.id, |
| 35 | user_agent: user_agent, | |
| 36 | remote_ip: remote_ip, | |
| 37 | action: "organization.create", | |
| 38 | params: params | |
| 39 | } | |
| 40 | end | |
| 41 | ||
| 42 | def build(%User{id: user_id}, user_agent, remote_ip, action, params) do | |
| 43 | 209 | params = extract_params(action, params) |
| 44 | ||
| 45 | 209 | %AuditLog{ |
| 46 | user_id: user_id, | |
| 47 | 209 | organization_id: params[:organization][:id] || params[:package][:organization_id], |
| 48 | user_agent: user_agent, | |
| 49 | remote_ip: remote_ip, | |
| 50 | action: action, | |
| 51 | params: params | |
| 52 | } | |
| 53 | end | |
| 54 | ||
| 55 | def build(%Organization{id: organization_id}, user_agent, remote_ip, action, params) do | |
| 56 | 4 | params = extract_params(action, params) |
| 57 | ||
| 58 | 4 | %AuditLog{ |
| 59 | user_id: nil, | |
| 60 | organization_id: organization_id, | |
| 61 | user_agent: user_agent, | |
| 62 | remote_ip: remote_ip, | |
| 63 | action: action, | |
| 64 | params: params | |
| 65 | } | |
| 66 | end | |
| 67 | ||
| 68 | def audit({user, user_agent, remote_ip}, action, params) do | |
| 69 | 31 | build(user, user_agent, remote_ip, action, params) |
| 70 | end | |
| 71 | ||
| 72 | def audit(multi, nil, _action, _fun) do | |
| 73 | 183 | multi |
| 74 | end | |
| 75 | ||
| 76 | def audit(multi, {user, user_agent, remote_ip}, action, fun) when is_function(fun, 1) do | |
| 77 | 128 | Multi.merge(multi, fn data -> |
| 78 | 102 | Multi.insert( |
| 79 | Multi.new(), | |
| 80 | multi_key(multi, action), | |
| 81 | build(user, user_agent, remote_ip, action, fun.(data)) | |
| 82 | ) | |
| 83 | end) | |
| 84 | end | |
| 85 | ||
| 86 | def audit(multi, {user, user_agent, remote_ip}, action, params) do | |
| 87 | 58 | Multi.insert( |
| 88 | multi, | |
| 89 | multi_key(multi, action), | |
| 90 | build(user, user_agent, remote_ip, action, params) | |
| 91 | ) | |
| 92 | end | |
| 93 | ||
| 94 | 2 | def audit_many(multi, who, action, list, opts \\ []) |
| 95 | ||
| 96 | def audit_many(multi, nil, _action, _list, _opts) do | |
| 97 | 0 | multi |
| 98 | end | |
| 99 | ||
| 100 | def audit_many(multi, {user, user_agent, remote_ip}, action, list, opts) do | |
| 101 | 2 | fields = AuditLog.__schema__(:fields) -- [:id] |
| 102 | 2 | extra = %{inserted_at: DateTime.utc_now()} |
| 103 | ||
| 104 | 2 | entries = |
| 105 | Enum.map(list, fn entry -> | |
| 106 | build(user, user_agent, remote_ip, action, entry) | |
| 107 | |> Map.take(fields) | |
| 108 | 4 | |> Map.merge(extra) |
| 109 | end) | |
| 110 | ||
| 111 | 2 | Multi.insert_all(multi, multi_key(multi, action), AuditLog, entries, opts) |
| 112 | end | |
| 113 | ||
| 114 | def audit_with_user(multi, nil, _action, _fun) do | |
| 115 | 0 | multi |
| 116 | end | |
| 117 | ||
| 118 | def audit_with_user(multi, {_user, user_agent, remote_ip}, action, fun) do | |
| 119 | 25 | Multi.insert(multi, multi_key(multi, action), fn %{user: user} = data -> |
| 120 | 17 | build(user, user_agent, remote_ip, action, fun.(data)) |
| 121 | end) | |
| 122 | end | |
| 123 | ||
| 124 | defp extract_params("docs.publish", {package, release}), | |
| 125 | 8 | do: %{package: serialize(package), release: serialize(release)} |
| 126 | ||
| 127 | defp extract_params("docs.revert", {package, release}), | |
| 128 | 3 | do: %{package: serialize(package), release: serialize(release)} |
| 129 | ||
| 130 | 7 | defp extract_params("key.generate", key), do: serialize(key) |
| 131 | 8 | defp extract_params("key.remove", key), do: serialize(key) |
| 132 | ||
| 133 | defp extract_params("owner.add", {package, level, user}), | |
| 134 | 9 | do: %{package: serialize(package), level: level, user: serialize(user)} |
| 135 | ||
| 136 | defp extract_params("owner.transfer", {package, level, user}), | |
| 137 | 2 | do: %{package: serialize(package), level: level, user: serialize(user)} |
| 138 | ||
| 139 | defp extract_params("owner.remove", {package, level, user}), | |
| 140 | 3 | do: %{package: serialize(package), level: level, user: serialize(user)} |
| 141 | ||
| 142 | defp extract_params("release.publish", {package, release}), | |
| 143 | 28 | do: %{package: serialize(package), release: serialize(release)} |
| 144 | ||
| 145 | defp extract_params("release.revert", {package, release}), | |
| 146 | 11 | do: %{package: serialize(package), release: serialize(release)} |
| 147 | ||
| 148 | defp extract_params("release.retire", {package, release}), | |
| 149 | 3 | do: %{package: serialize(package), release: serialize(release)} |
| 150 | ||
| 151 | defp extract_params("release.unretire", {package, release}), | |
| 152 | 3 | do: %{package: serialize(package), release: serialize(release)} |
| 153 | ||
| 154 | defp extract_params("email.add", {organization, email}), | |
| 155 | 4 | do: Map.merge(%{organization: serialize(organization)}, serialize(email)) |
| 156 | ||
| 157 | 18 | defp extract_params("email.add", email), do: serialize(email) |
| 158 | ||
| 159 | defp extract_params("email.remove", {organization, email}), | |
| 160 | 2 | do: Map.merge(%{organization: serialize(organization)}, serialize(email)) |
| 161 | ||
| 162 | 1 | defp extract_params("email.remove", email), do: serialize(email) |
| 163 | ||
| 164 | defp extract_params("email.primary", {old_email, new_email}), | |
| 165 | 6 | do: %{old_email: serialize(old_email), new_email: serialize(new_email)} |
| 166 | ||
| 167 | defp extract_params("email.public", {organization, {old_email, new_email}}), | |
| 168 | 3 | do: %{ |
| 169 | organization: serialize(organization), | |
| 170 | old_email: serialize(old_email), | |
| 171 | new_email: serialize(new_email) | |
| 172 | } | |
| 173 | ||
| 174 | defp extract_params("email.public", {old_email, new_email}), | |
| 175 | 8 | do: %{old_email: serialize(old_email), new_email: serialize(new_email)} |
| 176 | ||
| 177 | defp extract_params("email.gravatar", {organization, {old_email, new_email}}), | |
| 178 | 3 | do: %{ |
| 179 | organization: serialize(organization), | |
| 180 | old_email: serialize(old_email), | |
| 181 | new_email: serialize(new_email) | |
| 182 | } | |
| 183 | ||
| 184 | defp extract_params("email.gravatar", {old_email, new_email}), | |
| 185 | 2 | do: %{old_email: serialize(old_email), new_email: serialize(new_email)} |
| 186 | ||
| 187 | 5 | defp extract_params("user.create", user), do: serialize(user) |
| 188 | 18 | defp extract_params("user.update", user), do: serialize(user) |
| 189 | 4 | defp extract_params("security.update", user), do: serialize(user) |
| 190 | 1 | defp extract_params("security.rotate_recovery_codes", user), do: serialize(user) |
| 191 | 2 | defp extract_params("organization.create", organization), do: serialize(organization) |
| 192 | ||
| 193 | defp extract_params("organization.member.add", {organization, user}), | |
| 194 | 3 | do: %{organization: serialize(organization), user: serialize(user)} |
| 195 | ||
| 196 | defp extract_params("organization.member.remove", {organization, user}), | |
| 197 | 5 | do: %{organization: serialize(organization), user: serialize(user)} |
| 198 | ||
| 199 | defp extract_params("organization.member.role", {organization, user, role}), | |
| 200 | 2 | do: %{organization: serialize(organization), user: serialize(user), role: role} |
| 201 | ||
| 202 | 8 | defp extract_params("password.reset.init", nil), do: %{} |
| 203 | 2 | defp extract_params("password.reset.finish", nil), do: %{} |
| 204 | 4 | defp extract_params("password.update", nil), do: %{} |
| 205 | ||
| 206 | defp extract_params("billing.checkout", {organization, data}), | |
| 207 | 5 | do: %{ |
| 208 | organization: serialize(organization), | |
| 209 | payment_source: data[:payment_source] | |
| 210 | } | |
| 211 | ||
| 212 | defp extract_params("billing.cancel", {organization, _params}), | |
| 213 | 6 | do: %{organization: serialize(organization)} |
| 214 | ||
| 215 | defp extract_params("billing.create", {organization, params}), | |
| 216 | 5 | do: %{ |
| 217 | organization: serialize(organization), | |
| 218 | email: params["email"], | |
| 219 | person: params["person"], | |
| 220 | company: params["company"], | |
| 221 | token: params["token"], | |
| 222 | quantity: params["quantity"] | |
| 223 | } | |
| 224 | ||
| 225 | defp extract_params("billing.update", {organization, params}), | |
| 226 | 9 | do: %{ |
| 227 | organization: serialize(organization), | |
| 228 | email: params["email"], | |
| 229 | person: params["person"], | |
| 230 | company: params["company"], | |
| 231 | token: params["token"], | |
| 232 | quantity: params["quantity"] | |
| 233 | } | |
| 234 | ||
| 235 | defp extract_params("billing.change_plan", {organization, params}), | |
| 236 | 5 | do: %{ |
| 237 | organization: serialize(organization), | |
| 238 | plan_id: params["plan_id"] | |
| 239 | } | |
| 240 | ||
| 241 | defp extract_params("billing.pay_invoice", {organization, invoice_id}), | |
| 242 | 5 | do: %{ |
| 243 | organization: serialize(organization), | |
| 244 | invoice_id: invoice_id | |
| 245 | } | |
| 246 | ||
| 247 | defp serialize(%Key{} = key) do | |
| 248 | key | |
| 249 | |> do_serialize() | |
| 250 | 15 | |> Map.put(:permissions, Enum.map(key.permissions, &serialize/1)) |
| 251 | 15 | |> Map.put(:user, serialize(key.user)) |
| 252 | 15 | |> Map.put(:organization, serialize(key.organization)) |
| 253 | end | |
| 254 | ||
| 255 | defp serialize(%Package{} = package) do | |
| 256 | package | |
| 257 | |> do_serialize() | |
| 258 | 70 | |> Map.put(:meta, serialize(package.meta)) |
| 259 | end | |
| 260 | ||
| 261 | defp serialize(%Release{} = release) do | |
| 262 | release | |
| 263 | |> do_serialize() | |
| 264 | 56 | |> Map.put(:meta, serialize(release.meta)) |
| 265 | 56 | |> Map.put(:retirement, serialize(release.retirement)) |
| 266 | end | |
| 267 | ||
| 268 | defp serialize(%User{} = user) do | |
| 269 | user | |
| 270 | |> do_serialize() | |
| 271 | 62 | |> Map.put(:handles, serialize(user.handles)) |
| 272 | end | |
| 273 | ||
| 274 | 115 | defp serialize(nil), do: nil |
| 275 | 292 | defp serialize(schema), do: do_serialize(schema) |
| 276 | ||
| 277 | 495 | defp do_serialize(schema), do: Map.take(schema, fields(schema)) |
| 278 | ||
| 279 | 55 | defp fields(%Email{}), do: [:email, :primary, :public, :primary, :gravatar] |
| 280 | 15 | defp fields(%Key{}), do: [:id, :name] |
| 281 | 15 | defp fields(%KeyPermission{}), do: [:resource, :domain] |
| 282 | 70 | defp fields(%Package{}), do: [:id, :name, :organization_id] |
| 283 | 70 | defp fields(%PackageMetadata{}), do: [:description, :licenses, :links, :maintainers, :extra] |
| 284 | 56 | defp fields(%Release{}), do: [:id, :version, :checksum, :has_docs, :package_id] |
| 285 | 56 | defp fields(%ReleaseMetadata{}), do: [:app, :build_tools, :elixir] |
| 286 | 3 | defp fields(%ReleaseRetirement{}), do: [:status, :message] |
| 287 | 49 | defp fields(%Organization{}), do: [:id, :name, :public, :active, :billing_active] |
| 288 | 62 | defp fields(%User{}), do: [:id, :username] |
| 289 | 44 | defp fields(%UserHandles{}), do: [:github, :twitter, :freenode] |
| 290 | ||
| 291 | defp multi_key(multi, action) do | |
| 292 | 187 | :"log.#{action}.#{length(Multi.to_list(multi))}" |
| 293 | end | |
| 294 | ||
| 295 | def count_by(schema) do | |
| 296 | 5 | from(l in all_by(schema), select: count(l)) |
| 297 | end | |
| 298 | ||
| 299 | def all_by(%Hexpm.Repository.Package{} = package) do | |
| 300 | 18 | from(l in AuditLog, |
| 301 | 18 | where: fragment("(? -> 'package' ->> 'id')::integer", l.params) == ^package.id |
| 302 | ) | |
| 303 | end | |
| 304 | ||
| 305 | def all_by(%Hexpm.Accounts.Organization{} = organization) do | |
| 306 | 3 | Ecto.assoc(organization, :audit_logs) |
| 307 | end | |
| 308 | ||
| 309 | def all_by(%Hexpm.Accounts.User{} = user) do | |
| 310 | 36 | Ecto.assoc(user, :audit_logs) |
| 311 | end | |
| 312 | ||
| 313 | def newest_first(query) do | |
| 314 | 52 | Ecto.Query.order_by(query, desc: :inserted_at) |
| 315 | end | |
| 316 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Accounts.AuditLogs do | |
| 1 | use Hexpm.Context | |
| 2 | ||
| 3 | alias Hexpm.Accounts.AuditLog | |
| 4 | ||
| 5 | def all_by(schema) do | |
| 6 | AuditLog.all_by(schema) | |
| 7 | |> AuditLog.newest_first() | |
| 8 | 33 | |> Repo.all() |
| 9 | end | |
| 10 | ||
| 11 | def all_by(schema, page, per_page) do | |
| 12 | AuditLog.all_by(schema) | |
| 13 | |> AuditLog.newest_first() | |
| 14 | |> Hexpm.Utils.paginate(page, per_page) | |
| 15 | 19 | |> Repo.all() |
| 16 | end | |
| 17 | ||
| 18 | @doc """ | |
| 19 | Return the number of audit_logs belong to the schema (user/organization/package) | |
| 20 | """ | |
| 21 | def count_by(schema) do | |
| 22 | AuditLog.count_by(schema) | |
| 23 | 5 | |> Repo.one() |
| 24 | end | |
| 25 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Accounts.Auth do | |
| 1 | import Ecto.Query, only: [from: 2] | |
| 2 | ||
| 3 | alias Hexpm.Accounts.{Key, Keys, User, Users} | |
| 4 | ||
| 5 | def key_auth(user_secret, usage_info) do | |
| 6 | # Database index lookup on the first part of the key and then | |
| 7 | # secure compare on the second part to avoid timing attacks | |
| 8 | 248 | app_secret = Application.get_env(:hexpm, :secret) |
| 9 | ||
| 10 | 248 | <<first::binary-size(32), second::binary-size(32)>> = |
| 11 | :crypto.mac(:hmac, :sha256, app_secret, user_secret) | |
| 12 | |> Base.encode16(case: :lower) | |
| 13 | ||
| 14 | 248 | result = |
| 15 | from( | |
| 16 | k in Key, | |
| 17 | where: k.secret_first == ^first, | |
| 18 | left_join: u in assoc(k, :user), | |
| 19 | left_join: o in assoc(k, :organization), | |
| 20 | preload: [user: {u, [:emails, owned_packages: :repository, organizations: :repository]}], | |
| 21 | preload: [organization: {o, [:repository, :user]}] | |
| 22 | ) | |
| 23 | |> Hexpm.Repo.one() | |
| 24 | ||
| 25 | 248 | case result do |
| 26 | 7 | nil -> |
| 27 | :error | |
| 28 | ||
| 29 | key -> | |
| 30 | 241 | valid_auth = !key.user || !User.organization?(key.user) |
| 31 | ||
| 32 | 241 | if valid_auth && Hexpm.Utils.secure_check(key.secret_second, second) do |
| 33 | 241 | if Key.revoked?(key) do |
| 34 | :revoked | |
| 35 | else | |
| 36 | 236 | Keys.update_last_use(key, usage_info(usage_info)) |
| 37 | ||
| 38 | {:ok, | |
| 39 | %{ | |
| 40 | key: key, | |
| 41 | 236 | user: key.user, |
| 42 | 236 | organization: key.organization, |
| 43 | 236 | email: find_email(key.user, nil), |
| 44 | source: :key | |
| 45 | }} | |
| 46 | end | |
| 47 | else | |
| 48 | :error | |
| 49 | end | |
| 50 | end | |
| 51 | end | |
| 52 | ||
| 53 | def password_auth(username_or_email, password) do | |
| 54 | 32 | user = |
| 55 | Users.get(username_or_email, [ | |
| 56 | :emails, | |
| 57 | owned_packages: :repository, | |
| 58 | organizations: :repository | |
| 59 | ]) | |
| 60 | ||
| 61 | 32 | valid_user = user && !User.organization?(user) && user.password |
| 62 | ||
| 63 | 32 | if valid_user && Bcrypt.verify_pass(password, user.password) do |
| 64 | {:ok, | |
| 65 | %{ | |
| 66 | key: nil, | |
| 67 | user: user, | |
| 68 | organization: nil, | |
| 69 | email: find_email(user, username_or_email), | |
| 70 | source: :password | |
| 71 | }} | |
| 72 | else | |
| 73 | :error | |
| 74 | end | |
| 75 | end | |
| 76 | ||
| 77 | 0 | def gen_password(nil), do: nil |
| 78 | 44 | def gen_password(password), do: Bcrypt.hash_pwd_salt(password) |
| 79 | ||
| 80 | def gen_key() do | |
| 81 | :crypto.strong_rand_bytes(16) | |
| 82 | 434 | |> Base.encode16(case: :lower) |
| 83 | end | |
| 84 | ||
| 85 | 27 | defp find_email(nil, _email) do |
| 86 | nil | |
| 87 | end | |
| 88 | ||
| 89 | defp find_email(user, email) do | |
| 90 | 233 | Enum.find(user.emails, &(&1.email == email)) || Enum.find(user.emails, & &1.primary) |
| 91 | end | |
| 92 | ||
| 93 | defp usage_info(info) do | |
| 94 | 236 | %{ |
| 95 | ip: parse_ip(info[:ip]), | |
| 96 | used_at: info[:used_at], | |
| 97 | user_agent: parse_user_agent(info[:user_agent]) | |
| 98 | } | |
| 99 | end | |
| 100 | ||
| 101 | 2 | defp parse_ip(nil), do: nil |
| 102 | ||
| 103 | defp parse_ip(ip_tuple) do | |
| 104 | ip_tuple | |
| 105 | |> Tuple.to_list() | |
| 106 | 234 | |> Enum.join(".") |
| 107 | end | |
| 108 | ||
| 109 | 2 | defp parse_user_agent(nil), do: nil |
| 110 | 233 | defp parse_user_agent([]), do: nil |
| 111 | 1 | defp parse_user_agent([value | _]), do: value |
| 112 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Accounts.Email do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | @derive HexpmWeb.Stale | |
| 4 | @email_regex ~r"^.+@.+\..+$" | |
| 5 | ||
| 6 | 7067 | schema "emails" do |
| 7 | field :email, :string | |
| 8 | field :verified, :boolean, default: false | |
| 9 | field :primary, :boolean, default: false | |
| 10 | field :public, :boolean, default: false | |
| 11 | field :gravatar, :boolean, default: false | |
| 12 | field :verification_key, :string | |
| 13 | field :verification_expiry, :utc_datetime_usec | |
| 14 | ||
| 15 | belongs_to :user, User | |
| 16 | ||
| 17 | timestamps() | |
| 18 | end | |
| 19 | ||
| 20 | 15 | def changeset(email, type, params, verified? \\ not Application.get_env(:hexpm, :user_confirm)) |
| 21 | ||
| 22 | def changeset(email, :first, params, verified?) do | |
| 23 | changeset(email, :create, params, verified?) | |
| 24 | |> put_change(:primary, true) | |
| 25 | 9 | |> put_change(:public, true) |
| 26 | end | |
| 27 | ||
| 28 | def changeset(email, :create, params, verified?) do | |
| 29 | cast(email, params, ~w(email)a) | |
| 30 | |> validate_confirmation(:email, message: "does not match email") | |
| 31 | |> downcase_and_validate_email() | |
| 32 | |> validate_verified_email_exists(:email, message: "already in use") | |
| 33 | |> put_change(:verified, verified?) | |
| 34 | |> put_change(:verification_key, Auth.gen_key()) | |
| 35 | 25 | |> put_change(:verification_expiry, DateTime.utc_now()) |
| 36 | end | |
| 37 | ||
| 38 | def changeset(email, :create_for_org, params, false) do | |
| 39 | cast(email, params, ~w(email public gravatar)a) | |
| 40 | |> downcase_and_validate_email() | |
| 41 | 6 | |> put_change(:verified, false) |
| 42 | end | |
| 43 | ||
| 44 | def verification(email) do | |
| 45 | 1 | change(email, %{ |
| 46 | verification_key: Auth.gen_key(), | |
| 47 | verification_expiry: DateTime.utc_now() | |
| 48 | }) | |
| 49 | end | |
| 50 | ||
| 51 | 0 | def verify?(nil, _key), do: false |
| 52 | ||
| 53 | def verify?(email, key) do | |
| 54 | 5 | email_key = email.verification_key |
| 55 | 5 | valid_key? = !!(email_key && Hexpm.Utils.secure_check(email_key, key)) |
| 56 | 5 | within_time? = Hexpm.Utils.within_last_day?(email.verification_expiry) |
| 57 | 5 | valid_key? and within_time? |
| 58 | end | |
| 59 | ||
| 60 | def verify(email) do | |
| 61 | change(email, %{ | |
| 62 | verified: true, | |
| 63 | verification_key: nil, | |
| 64 | verification_expiry: nil | |
| 65 | }) | |
| 66 | 3 | |> unique_constraint(:email, name: "emails_email_key", message: "already in use") |
| 67 | end | |
| 68 | ||
| 69 | def update_email(email, new_address) do | |
| 70 | change(email, %{email: new_address}) | |
| 71 | 3 | |> downcase_and_validate_email() |
| 72 | end | |
| 73 | ||
| 74 | def toggle_flag(email, flag, value) do | |
| 75 | 15 | change(email, %{flag => value}) |
| 76 | end | |
| 77 | ||
| 78 | def order_emails(emails) do | |
| 79 | 3 | Enum.sort_by(emails, &[not &1.primary, not &1.public, not &1.verified, -&1.id]) |
| 80 | end | |
| 81 | ||
| 82 | defp downcase_and_validate_email(changeset) do | |
| 83 | changeset | |
| 84 | |> validate_required(~w(email)a) | |
| 85 | |> update_change(:email, &String.downcase/1) | |
| 86 | |> validate_format(:email, @email_regex) | |
| 87 | |> unique_constraint(:email, name: "emails_email_key") | |
| 88 | 34 | |> unique_constraint(:email, name: "emails_email_user_key") |
| 89 | end | |
| 90 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Accounts.Key do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | @derive HexpmWeb.Stale | |
| 4 | @derive {Phoenix.Param, key: :name} | |
| 5 | ||
| 6 | @days_30 60 * 60 * 24 * 30 | |
| 7 | ||
| 8 | 2223 | schema "keys" do |
| 9 | field :name, :string | |
| 10 | field :secret_first, :string | |
| 11 | field :secret_second, :string | |
| 12 | field :public, :boolean, default: true | |
| 13 | field :revoke_at, :utc_datetime_usec | |
| 14 | field :revoked_at, :utc_datetime_usec | |
| 15 | timestamps() | |
| 16 | ||
| 17 | 256 | embeds_one :last_use, Use, on_replace: :delete do |
| 18 | field :used_at, :utc_datetime_usec | |
| 19 | field :user_agent, :string | |
| 20 | field :ip, :string | |
| 21 | end | |
| 22 | ||
| 23 | belongs_to :user, User | |
| 24 | belongs_to :organization, Organization | |
| 25 | embeds_many :permissions, KeyPermission | |
| 26 | ||
| 27 | # Only used after key creation to hold the user's key (not hashed) | |
| 28 | # the user key will never be retrievable after this | |
| 29 | field :user_secret, :string, virtual: true | |
| 30 | end | |
| 31 | ||
| 32 | def changeset(key, user_or_organization, params) do | |
| 33 | cast(key, params, ~w(name)a) | |
| 34 | |> validate_required(~w(name)a) | |
| 35 | |> add_keys() | |
| 36 | |> prepare_changes(&unique_name/1) | |
| 37 | |> unique_constraint(:name, name: "_name_revoked_at_key", match: :suffix) | |
| 38 | 4 | |> cast_embed(:permissions, with: &KeyPermission.changeset(&1, user_or_organization, &2)) |
| 39 | 231 | |> put_default_embed(:permissions, [%KeyPermission{domain: "api"}]) |
| 40 | end | |
| 41 | ||
| 42 | def build(user_or_organization, params) do | |
| 43 | build_assoc(user_or_organization, :keys) | |
| 44 | |> associate_owner(user_or_organization) | |
| 45 | 218 | |> changeset(user_or_organization, params) |
| 46 | end | |
| 47 | ||
| 48 | def build_for_docs(user, organization) do | |
| 49 | 3 | permission = |
| 50 | KeyPermission.changeset(%KeyPermission{}, user, %{ | |
| 51 | "domain" => "docs", | |
| 52 | "resource" => organization | |
| 53 | }) | |
| 54 | ||
| 55 | 3 | revoke_at = |
| 56 | NaiveDateTime.add(NaiveDateTime.utc_now(), @days_30) |> DateTime.from_naive!("Etc/UTC") | |
| 57 | ||
| 58 | build_assoc(user, :keys) | |
| 59 | |> change() | |
| 60 | |> add_keys() | |
| 61 | |> put_change(:revoke_at, revoke_at) | |
| 62 | |> put_change(:public, false) | |
| 63 | 3 | |> put_embed(:permissions, [permission]) |
| 64 | end | |
| 65 | ||
| 66 | defmacrop query_revoked(key) do | |
| 67 | quote do | |
| 68 | not is_nil(unquote(key).revoked_at) or | |
| 69 | (not is_nil(unquote(key).revoke_at) and unquote(key).revoke_at < fragment("NOW()")) | |
| 70 | end | |
| 71 | end | |
| 72 | ||
| 73 | def all(user_or_organization) do | |
| 74 | 21 | from( |
| 75 | k in assoc(user_or_organization, :keys), | |
| 76 | where: not query_revoked(k), | |
| 77 | where: k.public | |
| 78 | ) | |
| 79 | end | |
| 80 | ||
| 81 | def get(user_or_organization, name) do | |
| 82 | 19 | from( |
| 83 | k in assoc(user_or_organization, :keys), | |
| 84 | where: k.name == ^name, | |
| 85 | where: not query_revoked(k) | |
| 86 | ) | |
| 87 | end | |
| 88 | ||
| 89 | def get_revoked(user_or_organization, name) do | |
| 90 | 4 | from( |
| 91 | k in assoc(user_or_organization, :keys), | |
| 92 | where: k.name == ^name, | |
| 93 | where: query_revoked(k) | |
| 94 | ) | |
| 95 | end | |
| 96 | ||
| 97 | def revoke(key, revoked_at \\ DateTime.utc_now()) do | |
| 98 | key | |
| 99 | |> change() | |
| 100 | 4 | |> put_change(:revoked_at, key.revoked_at || revoked_at) |
| 101 | 4 | |> validate_required(:revoked_at) |
| 102 | end | |
| 103 | ||
| 104 | def revoke_by_name(user_or_organization, key_name, revoked_at \\ DateTime.utc_now()) do | |
| 105 | 0 | from( |
| 106 | k in assoc(user_or_organization, :keys), | |
| 107 | where: k.name == ^key_name and not query_revoked(k), | |
| 108 | update: [ | |
| 109 | set: [ | |
| 110 | revoked_at: ^revoked_at, | |
| 111 | updated_at: ^DateTime.utc_now() | |
| 112 | ] | |
| 113 | ] | |
| 114 | ) | |
| 115 | end | |
| 116 | ||
| 117 | def revoke_all(user_or_organization, revoked_at \\ DateTime.utc_now()) do | |
| 118 | 2 | from( |
| 119 | k in assoc(user_or_organization, :keys), | |
| 120 | where: not query_revoked(k), | |
| 121 | update: [ | |
| 122 | set: [ | |
| 123 | revoked_at: ^revoked_at, | |
| 124 | updated_at: ^DateTime.utc_now() | |
| 125 | ] | |
| 126 | ] | |
| 127 | ) | |
| 128 | end | |
| 129 | ||
| 130 | def gen_key() do | |
| 131 | 398 | user_secret = Auth.gen_key() |
| 132 | 398 | app_secret = Application.get_env(:hexpm, :secret) |
| 133 | ||
| 134 | 398 | <<first::binary-size(32), second::binary-size(32)>> = |
| 135 | :crypto.mac(:hmac, :sha256, app_secret, user_secret) | |
| 136 | |> Base.encode16(case: :lower) | |
| 137 | ||
| 138 | 398 | {user_secret, first, second} |
| 139 | end | |
| 140 | ||
| 141 | def update_last_use(key, params) do | |
| 142 | key | |
| 143 | |> change() | |
| 144 | 233 | |> put_embed(:last_use, struct(Key.Use, params)) |
| 145 | end | |
| 146 | ||
| 147 | defp add_keys(changeset) do | |
| 148 | 234 | {user_secret, first, second} = gen_key() |
| 149 | ||
| 150 | changeset | |
| 151 | |> put_change(:user_secret, user_secret) | |
| 152 | |> put_change(:secret_first, first) | |
| 153 | 234 | |> put_change(:secret_second, second) |
| 154 | end | |
| 155 | ||
| 156 | defp unique_name(changeset) do | |
| 157 | 216 | {:ok, name} = fetch_change(changeset, :name) |
| 158 | ||
| 159 | 216 | source = |
| 160 | 216 | if changeset.data.organization_id do |
| 161 | 15 | assoc(changeset.data, :organization) |
| 162 | else | |
| 163 | 201 | assoc(changeset.data, :user) |
| 164 | end | |
| 165 | ||
| 166 | 216 | names = |
| 167 | 216 | from( |
| 168 | s in source, | |
| 169 | join: k in assoc(s, :keys), | |
| 170 | where: not query_revoked(k), | |
| 171 | where: k.name == ^name or like(k.name, ^(name <> "-%")), | |
| 172 | select: k.name | |
| 173 | ) | |
| 174 | 216 | |> changeset.repo.all |
| 175 | ||
| 176 | 216 | name = if name in names, do: find_unique_name(name, names), else: name |
| 177 | ||
| 178 | 216 | put_change(changeset, :name, name) |
| 179 | end | |
| 180 | ||
| 181 | defp find_unique_name(name, names) do | |
| 182 | 16 | max = |
| 183 | names | |
| 184 | |> Enum.flat_map(fn existing_name -> | |
| 185 | 21 | case Integer.parse(String.trim_leading(existing_name, name <> "-")) do |
| 186 | 1 | {num, ""} -> [num] |
| 187 | 20 | _ -> [] |
| 188 | end | |
| 189 | end) | |
| 190 | 15 | |> Enum.max(&>=/2, fn -> 1 end) |
| 191 | ||
| 192 | 16 | "#{name}-#{max + 1}" |
| 193 | end | |
| 194 | ||
| 195 | def verify_permissions?(key, "api", resource) do | |
| 196 | 216 | Enum.any?(key.permissions, fn permission -> |
| 197 | 216 | permission.domain == "api" and match_api_resource?(permission.resource, resource) |
| 198 | end) | |
| 199 | end | |
| 200 | ||
| 201 | def verify_permissions?(key, "repositories", _resource) do | |
| 202 | 7 | Enum.any?(key.permissions, &(&1.domain == "repositories")) |
| 203 | end | |
| 204 | ||
| 205 | def verify_permissions?(key, "repository", resource) do | |
| 206 | 24 | Enum.any?(key.permissions, fn permission -> |
| 207 | 32 | (permission.domain == "repository" and permission.resource == resource) or |
| 208 | 26 | permission.domain == "repositories" |
| 209 | end) | |
| 210 | end | |
| 211 | ||
| 212 | def verify_permissions?(key, "docs", resource) do | |
| 213 | 4 | Enum.any?(key.permissions, fn permission -> |
| 214 | 4 | permission.domain == "docs" and permission.resource == resource |
| 215 | end) | |
| 216 | end | |
| 217 | ||
| 218 | 0 | def verify_permissions?(_key, nil, _resource) do |
| 219 | false | |
| 220 | end | |
| 221 | ||
| 222 | 199 | defp match_api_resource?(nil, _resource), do: true |
| 223 | 3 | defp match_api_resource?("write", "write"), do: true |
| 224 | 1 | defp match_api_resource?("write", "read"), do: true |
| 225 | 3 | defp match_api_resource?("read", "read"), do: true |
| 226 | 3 | defp match_api_resource?(_key_resource, _resource), do: false |
| 227 | ||
| 228 | def revoked?(%Key{} = key) do | |
| 229 | 241 | not is_nil(key.revoked_at) or |
| 230 | 237 | (not is_nil(key.revoke_at) and DateTime.compare(key.revoke_at, DateTime.utc_now()) == :lt) |
| 231 | end | |
| 232 | ||
| 233 | 2 | def associate_owner(nil, _owner), do: nil |
| 234 | 212 | def associate_owner(%Key{} = key, %User{} = user), do: %{key | user: user, organization: nil} |
| 235 | ||
| 236 | def associate_owner(%Key{} = key, %Organization{} = organization), | |
| 237 | 20 | do: %{key | user: nil, organization: organization} |
| 238 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Accounts.KeyPermission do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | @derive HexpmWeb.Stale | |
| 4 | @domains ~w(api repository repositories docs) | |
| 5 | ||
| 6 | 785 | embedded_schema do |
| 7 | field :domain, :string | |
| 8 | field :resource, :string | |
| 9 | end | |
| 10 | ||
| 11 | def changeset(struct, user_or_organization, params) do | |
| 12 | cast(struct, params, ~w(domain resource)a) | |
| 13 | |> validate_inclusion(:domain, @domains) | |
| 14 | |> validate_resource() | |
| 15 | 7 | |> validate_permission(user_or_organization) |
| 16 | end | |
| 17 | ||
| 18 | defp validate_permission(changeset, user_or_organization) do | |
| 19 | 7 | validate_change(changeset, :resource, fn _, resource -> |
| 20 | 6 | domain = get_change(changeset, :domain) |
| 21 | ||
| 22 | 6 | case verify_permissions(user_or_organization, domain, resource) do |
| 23 | 4 | {:ok, _} -> |
| 24 | [] | |
| 25 | ||
| 26 | 2 | :error -> |
| 27 | # NOTE: Possibly change repository if we add more domains | |
| 28 | [resource: "you do not have access to this repository"] | |
| 29 | end | |
| 30 | end) | |
| 31 | end | |
| 32 | ||
| 33 | defp validate_resource(changeset) do | |
| 34 | 7 | validate_change(changeset, :resource, fn _, resource -> |
| 35 | 6 | case get_change(changeset, :domain) do |
| 36 | 0 | nil -> [] |
| 37 | 0 | "api" when resource in [nil, "read", "write"] -> [] |
| 38 | 3 | "repository" when is_binary(resource) -> [] |
| 39 | 3 | "docs" when is_binary(resource) -> [] |
| 40 | 0 | "repositories" when is_nil(resource) -> [] |
| 41 | 0 | _ -> [resource: "invalid resource for given domain"] |
| 42 | end | |
| 43 | end) | |
| 44 | end | |
| 45 | ||
| 46 | def verify_permissions(%User{} = user, domain, resource), | |
| 47 | 22 | do: User.verify_permissions(user, domain, resource) |
| 48 | ||
| 49 | def verify_permissions(%Organization{} = organization, domain, resource), | |
| 50 | 8 | do: Organization.verify_permissions(organization, domain, resource) |
| 51 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Accounts.Keys do | |
| 1 | use Hexpm.Context | |
| 2 | ||
| 3 | def all(user_or_organization) do | |
| 4 | Key.all(user_or_organization) | |
| 5 | |> Repo.all() | |
| 6 | 18 | |> Enum.map(&Key.associate_owner(&1, user_or_organization)) |
| 7 | end | |
| 8 | ||
| 9 | def get(id) do | |
| 10 | Repo.get(Key, id) | |
| 11 | 1 | |> Repo.preload([:organization, :user]) |
| 12 | end | |
| 13 | ||
| 14 | def get(user_or_organization, name) do | |
| 15 | Repo.one(Key.get(user_or_organization, name)) | |
| 16 | 7 | |> Key.associate_owner(user_or_organization) |
| 17 | end | |
| 18 | ||
| 19 | def create(user_or_organization, params, audit: audit_data) do | |
| 20 | Multi.new() | |
| 21 | |> Multi.insert(:key, Key.build(user_or_organization, params)) | |
| 22 | 6 | |> audit(audit_data, "key.generate", fn %{key: key} -> key end) |
| 23 | |> Repo.transaction() | |
| 24 | 191 | |> maybe_retry_for_unique_name(fn -> |
| 25 | 0 | create(user_or_organization, params, audit: audit_data) |
| 26 | end) | |
| 27 | end | |
| 28 | ||
| 29 | def create_for_docs(user, organization) do | |
| 30 | Key.build_for_docs(user, organization) | |
| 31 | 3 | |> Repo.insert() |
| 32 | end | |
| 33 | ||
| 34 | def revoke(key, audit: audit_data) do | |
| 35 | Multi.new() | |
| 36 | |> Multi.update(:key, Key.revoke(key)) | |
| 37 | |> audit(audit_data, "key.remove", key) | |
| 38 | 4 | |> Repo.transaction() |
| 39 | end | |
| 40 | ||
| 41 | def revoke(user_or_organization, name, audit: audit_data) do | |
| 42 | 6 | if key = get(user_or_organization, name) do |
| 43 | 4 | revoke(key, audit: audit_data) |
| 44 | else | |
| 45 | {:error, :not_found} | |
| 46 | end | |
| 47 | end | |
| 48 | ||
| 49 | def revoke_all(user_or_organization, audit: audit_data) do | |
| 50 | Multi.new() | |
| 51 | |> Multi.update_all(:keys, Key.revoke_all(user_or_organization), []) | |
| 52 | |> audit_many(audit_data, "key.remove", all(user_or_organization)) | |
| 53 | 1 | |> Repo.transaction() |
| 54 | end | |
| 55 | ||
| 56 | def update_last_use(%Key{public: true} = key, usage_info) do | |
| 57 | 235 | if Repo.write_mode?() do |
| 58 | key | |
| 59 | |> Key.update_last_use(usage_info) | |
| 60 | 233 | |> Repo.update!() |
| 61 | end | |
| 62 | end | |
| 63 | ||
| 64 | def update_last_use(%Key{public: false} = key, _usage_info) do | |
| 65 | 1 | key |
| 66 | end | |
| 67 | ||
| 68 | defp maybe_retry_for_unique_name( | |
| 69 | {:error, :key, %Ecto.Changeset{errors: [{:name, {"has already been taken", _}}]}, _}, | |
| 70 | fun | |
| 71 | ) do | |
| 72 | 0 | fun.() |
| 73 | end | |
| 74 | ||
| 75 | defp maybe_retry_for_unique_name(other, _fun) do | |
| 76 | 190 | other |
| 77 | end | |
| 78 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Accounts.Organization do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | @derive HexpmWeb.Stale | |
| 4 | @derive {Phoenix.Param, key: :name} | |
| 5 | @month_seconds 31 * 24 * 60 * 60 | |
| 6 | ||
| 7 | 2441 | schema "organizations" do |
| 8 | field :name, :string | |
| 9 | field :billing_active, :boolean, default: false | |
| 10 | field :trial_end, :utc_datetime_usec | |
| 11 | timestamps() | |
| 12 | ||
| 13 | has_one :repository, Repository | |
| 14 | has_one :user, User | |
| 15 | has_many :organization_users, OrganizationUser | |
| 16 | has_many :users, through: [:organization_users, :user] | |
| 17 | has_many :keys, Key | |
| 18 | has_many :audit_logs, AuditLog, foreign_key: :organization_id | |
| 19 | end | |
| 20 | ||
| 21 | @name_regex ~r"^[a-z0-9_\-\.]+$" | |
| 22 | @roles ~w(admin write read) | |
| 23 | ||
| 24 | @reserved_names ~w(www staging elixir erlang otp rebar rebar3 phoenix acme) | |
| 25 | ||
| 26 | def changeset(struct, params) do | |
| 27 | cast(struct, params, ~w(name)a) | |
| 28 | |> put_change(:trial_end, default_trial_end()) | |
| 29 | |> validate_required(~w(name)a) | |
| 30 | |> unique_constraint(:name) | |
| 31 | |> update_change(:name, &String.downcase/1) | |
| 32 | |> validate_length(:name, min: 3) | |
| 33 | |> validate_format(:name, @name_regex) | |
| 34 | 2 | |> validate_exclusion(:name, @reserved_names) |
| 35 | end | |
| 36 | ||
| 37 | def build_from_user(user) do | |
| 38 | 0 | changeset(%Organization{}, %{name: user.username}) |
| 39 | end | |
| 40 | ||
| 41 | def add_member(struct, params) do | |
| 42 | cast(struct, params, ~w(role)a) | |
| 43 | |> validate_required(~w(role)a) | |
| 44 | |> validate_inclusion(:role, @roles) | |
| 45 | 15 | |> unique_constraint( |
| 46 | :user_id, | |
| 47 | name: "organization_users_organization_id_user_id_index", | |
| 48 | message: "is already member" | |
| 49 | ) | |
| 50 | end | |
| 51 | ||
| 52 | def change_role(struct, params) do | |
| 53 | cast(struct, params, ~w(role)a) | |
| 54 | |> validate_required(~w(role)a) | |
| 55 | 2 | |> validate_inclusion(:role, @roles) |
| 56 | end | |
| 57 | ||
| 58 | def access(organization, user, role) do | |
| 59 | 84 | from( |
| 60 | ou in OrganizationUser, | |
| 61 | 84 | where: ou.organization_id == ^organization.id, |
| 62 | 84 | where: ou.user_id == ^user.id, |
| 63 | where: ou.role in ^role_or_higher(role), | |
| 64 | select: count(ou.id) >= 1 | |
| 65 | ) | |
| 66 | end | |
| 67 | ||
| 68 | 61 | def role_or_higher("read"), do: ["read", "write", "admin"] |
| 69 | 32 | def role_or_higher("write"), do: ["write", "admin"] |
| 70 | 58 | def role_or_higher("admin"), do: ["admin"] |
| 71 | ||
| 72 | def hexpm(opts \\ []) do | |
| 73 | 81 | repository = |
| 74 | if Keyword.get(opts, :recursive, true) do | |
| 75 | 38 | Repository.hexpm(recursive: false) |
| 76 | else | |
| 77 | 43 | %Ecto.Association.NotLoaded{} |
| 78 | end | |
| 79 | ||
| 80 | 81 | %__MODULE__{ |
| 81 | id: 1, | |
| 82 | name: "hexpm", | |
| 83 | billing_active: true, | |
| 84 | repository: repository | |
| 85 | } | |
| 86 | end | |
| 87 | ||
| 88 | 6 | def verify_permissions(%Organization{}, "api", _resource) do |
| 89 | {:ok, nil} | |
| 90 | end | |
| 91 | ||
| 92 | 2 | def verify_permissions(%Organization{name: name} = organization, domain, name) |
| 93 | when domain in ["repository", "docs"] do | |
| 94 | {:ok, organization} | |
| 95 | end | |
| 96 | ||
| 97 | 0 | def verify_permissions(%Organization{}, _domain, _resource) do |
| 98 | :error | |
| 99 | end | |
| 100 | ||
| 101 | def billing_active?(%Organization{billing_active: active} = organization) do | |
| 102 | 31 | active or trialing?(organization) |
| 103 | end | |
| 104 | ||
| 105 | def trialing?(%Organization{trial_end: trial_end}) do | |
| 106 | 5 | DateTime.compare(trial_end, DateTime.utc_now()) == :gt |
| 107 | end | |
| 108 | ||
| 109 | defp default_trial_end() do | |
| 110 | DateTime.utc_now() | |
| 111 | |> DateTime.add(@month_seconds) | |
| 112 | 2 | |> to_start_of_day() |
| 113 | end | |
| 114 | ||
| 115 | defp to_start_of_day(%DateTime{} = datetime) do | |
| 116 | 2 | %DateTime{datetime | hour: 0, minute: 0, second: 0} |
| 117 | end | |
| 118 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Accounts.OrganizationUser do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | 2162 | schema "organization_users" do |
| 4 | field :role, :string | |
| 5 | ||
| 6 | belongs_to :organization, Organization | |
| 7 | belongs_to :user, User | |
| 8 | ||
| 9 | timestamps() | |
| 10 | end | |
| 11 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Accounts.Organizations do | |
| 1 | use Hexpm.Context | |
| 2 | ||
| 3 | def all_by_user(user, preload \\ []) do | |
| 4 | Repo.all(assoc(user, :organizations)) | |
| 5 | 5 | |> Repo.preload(preload) |
| 6 | end | |
| 7 | ||
| 8 | def get(name, preload \\ []) do | |
| 9 | Repo.get_by(Organization, name: name) | |
| 10 | 122 | |> Repo.preload(preload) |
| 11 | end | |
| 12 | ||
| 13 | def get_role(organization, user) do | |
| 14 | 40 | org_user = Repo.get_by(OrganizationUser, organization_id: organization.id, user_id: user.id) |
| 15 | 40 | org_user && org_user.role |
| 16 | end | |
| 17 | ||
| 18 | def preload(organization, preload) do | |
| 19 | 0 | Repo.preload(organization, preload) |
| 20 | end | |
| 21 | ||
| 22 | 15 | def access?(_organization, nil = _user, _role) do |
| 23 | false | |
| 24 | end | |
| 25 | ||
| 26 | 3 | def access?(%Organization{id: id}, %Organization{id: id}, _role) do |
| 27 | true | |
| 28 | end | |
| 29 | ||
| 30 | def access?(organization, user, role) do | |
| 31 | 84 | Repo.one!(Organization.access(organization, user, role)) |
| 32 | end | |
| 33 | ||
| 34 | def create(user, params, audit: audit_data) do | |
| 35 | 2 | multi = |
| 36 | Multi.new() | |
| 37 | |> Multi.insert(:organization, Organization.changeset(%Organization{}, params)) | |
| 38 | |> Multi.insert(:repository, fn %{organization: organization} -> | |
| 39 | 1 | %Repository{name: organization.name, organization_id: organization.id} |
| 40 | end) | |
| 41 | 1 | |> Multi.insert(:user, &User.build_organization(&1.organization)) |
| 42 | |> Multi.insert(:organization_user, fn %{organization: organization} -> | |
| 43 | 1 | organization_user = %OrganizationUser{ |
| 44 | 1 | organization_id: organization.id, |
| 45 | 1 | user_id: user.id, |
| 46 | role: "admin" | |
| 47 | } | |
| 48 | ||
| 49 | 1 | Organization.add_member(organization_user, %{}) |
| 50 | end) | |
| 51 | 1 | |> audit(audit_data, "organization.create", & &1.organization) |
| 52 | ||
| 53 | 2 | case Repo.transaction(multi) do |
| 54 | 1 | {:ok, result} -> {:ok, result.organization} |
| 55 | 0 | {:error, :user, changeset, _} -> {:error, changeset} |
| 56 | 1 | {:error, :organization, changeset, _} -> {:error, changeset} |
| 57 | end | |
| 58 | end | |
| 59 | ||
| 60 | def create_from_user(organization_user, admin_user) do | |
| 61 | 0 | multi = |
| 62 | Multi.new() | |
| 63 | |> Multi.insert(:organization, Organization.build_from_user(organization_user)) | |
| 64 | |> Multi.insert(:repository, fn %{organization: organization} -> | |
| 65 | 0 | %Repository{name: organization.name, organization_id: organization.id} |
| 66 | end) | |
| 67 | 0 | |> Multi.update(:user, &User.to_organization(organization_user, &1.organization)) |
| 68 | |> Multi.insert(:organization_user, fn %{organization: organization} -> | |
| 69 | 0 | organization_user = %OrganizationUser{ |
| 70 | 0 | organization_id: organization.id, |
| 71 | 0 | user_id: admin_user.id, |
| 72 | role: "admin" | |
| 73 | } | |
| 74 | ||
| 75 | 0 | Organization.add_member(organization_user, %{}) |
| 76 | end) | |
| 77 | ||
| 78 | 0 | Repo.transaction(multi) |
| 79 | end | |
| 80 | ||
| 81 | def merge_with_user( | |
| 82 | %Organization{name: name} = organization, | |
| 83 | %User{username: name, organization_id: nil} = user | |
| 84 | ) do | |
| 85 | 0 | Repo.update(User.to_organization(user, organization)) |
| 86 | end | |
| 87 | ||
| 88 | 1 | def add_member(_organization, %User{organization_id: id}, _params, _opts) when is_integer(id) do |
| 89 | {:error, :organization_user} | |
| 90 | end | |
| 91 | ||
| 92 | def add_member(organization, %User{organization_id: nil} = user, params, audit: audit_data) do | |
| 93 | 3 | organization_user = %OrganizationUser{organization_id: organization.id, user_id: user.id} |
| 94 | ||
| 95 | 3 | multi = |
| 96 | Multi.new() | |
| 97 | |> Multi.insert(:organization_user, Organization.add_member(organization_user, params)) | |
| 98 | |> audit(audit_data, "organization.member.add", {organization, user}) | |
| 99 | ||
| 100 | 3 | case Repo.transaction(multi) do |
| 101 | {:ok, result} -> | |
| 102 | 2 | send_invite_email(organization, user) |
| 103 | 2 | {:ok, result.organization_user} |
| 104 | ||
| 105 | 1 | {:error, :organization_user, changeset, _} -> |
| 106 | {:error, changeset} | |
| 107 | end | |
| 108 | end | |
| 109 | ||
| 110 | def remove_member(organization, user, audit: audit_data) do | |
| 111 | 7 | count = Repo.aggregate(assoc(organization, :organization_users), :count, :id) |
| 112 | ||
| 113 | 7 | if count == 1 do |
| 114 | {:error, :last_member} | |
| 115 | else | |
| 116 | 5 | organization_user = Repo.get_by(assoc(organization, :organization_users), user_id: user.id) |
| 117 | ||
| 118 | 5 | if organization_user do |
| 119 | 5 | {:ok, _result} = |
| 120 | Multi.new() | |
| 121 | |> Multi.delete(:organization_user, organization_user) | |
| 122 | |> delete_package_owners(organization, user) | |
| 123 | |> audit(audit_data, "organization.member.remove", {organization, user}) | |
| 124 | |> Repo.transaction() | |
| 125 | end | |
| 126 | ||
| 127 | :ok | |
| 128 | end | |
| 129 | end | |
| 130 | ||
| 131 | def change_role(organization, user, params, audit: audit_data) do | |
| 132 | 3 | organization_users = Repo.all(assoc(organization, :organization_users)) |
| 133 | 3 | organization_user = Enum.find(organization_users, &(&1.user_id == user.id)) |
| 134 | 3 | number_admins = Enum.count(organization_users, &(&1.role == "admin")) |
| 135 | ||
| 136 | 3 | cond do |
| 137 | 3 | !organization_user -> |
| 138 | {:error, :unknown_user} | |
| 139 | ||
| 140 | 3 | organization_user.role == "admin" and number_admins == 1 -> |
| 141 | {:error, :last_admin} | |
| 142 | ||
| 143 | 2 | true -> |
| 144 | 2 | multi = |
| 145 | Multi.new() | |
| 146 | |> Multi.update(:organization_user, Organization.change_role(organization_user, params)) | |
| 147 | |> audit(audit_data, "organization.member.role", {organization, user, params["role"]}) | |
| 148 | ||
| 149 | 2 | case Repo.transaction(multi) do |
| 150 | 2 | {:ok, result} -> |
| 151 | 2 | {:ok, result.organization_user} |
| 152 | ||
| 153 | 0 | {:error, :organization_user, changeset, _} -> |
| 154 | {:error, changeset} | |
| 155 | end | |
| 156 | end | |
| 157 | end | |
| 158 | ||
| 159 | def user_count(organization) do | |
| 160 | 16 | Repo.aggregate(assoc(organization, :organization_users), :count, :id) |
| 161 | end | |
| 162 | ||
| 163 | defp delete_package_owners(multi, organization, user) do | |
| 164 | 5 | Multi.delete_all(multi, :package_owners, fn _changes -> |
| 165 | 5 | from( |
| 166 | po in PackageOwner, | |
| 167 | join: p in assoc(po, :package), | |
| 168 | join: r in assoc(p, :repository), | |
| 169 | 5 | where: r.organization_id == ^organization.id, |
| 170 | 5 | where: po.user_id == ^user.id |
| 171 | ) | |
| 172 | end) | |
| 173 | end | |
| 174 | ||
| 175 | defp send_invite_email(organization, user) do | |
| 176 | Emails.organization_invite(organization, user) | |
| 177 | 2 | |> Mailer.deliver_later!() |
| 178 | end | |
| 179 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Accounts.PasswordReset do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | 1248 | schema "password_resets" do |
| 4 | field :key, :string | |
| 5 | field :primary_email, :string | |
| 6 | belongs_to :user, User | |
| 7 | ||
| 8 | timestamps(updated_at: false) | |
| 9 | end | |
| 10 | ||
| 11 | def changeset(reset, user) do | |
| 12 | 7 | change(reset, %{ |
| 13 | key: Auth.gen_key(), | |
| 14 | primary_email: User.email(user, :primary) | |
| 15 | }) | |
| 16 | end | |
| 17 | ||
| 18 | def can_reset?(reset, primary_email, key) do | |
| 19 | 9 | valid_email? = primary_email == reset.primary_email |
| 20 | 9 | valid_key? = !!(reset.key && Hexpm.Utils.secure_check(reset.key, key)) |
| 21 | 9 | within_time? = Hexpm.Utils.within_last_day?(reset.inserted_at) |
| 22 | ||
| 23 | 9 | valid_email? and valid_key? and within_time? |
| 24 | end | |
| 25 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Accounts.RecoveryCode do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | alias Hexpm.Accounts.RecoveryCode | |
| 4 | ||
| 5 | @derive {Jason.Encoder, only: []} | |
| 6 | ||
| 7 | @rand_bytes 10 | |
| 8 | @part_size 4 | |
| 9 | ||
| 10 | 140 | embedded_schema do |
| 11 | field :code, :string | |
| 12 | field :used_at, :utc_datetime_usec | |
| 13 | end | |
| 14 | ||
| 15 | def changeset(recovery_code, params) do | |
| 16 | 0 | cast(recovery_code, params, [:code, :used_at]) |
| 17 | end | |
| 18 | ||
| 19 | def generate_set() do | |
| 20 | 2 | Enum.map(1..10, fn _ -> %RecoveryCode{code: generate()} end) |
| 21 | end | |
| 22 | ||
| 23 | def generate() do | |
| 24 | :crypto.strong_rand_bytes(@rand_bytes) | |
| 25 | |> Base.hex_encode32(case: :lower) | |
| 26 | |> String.to_charlist() | |
| 27 | |> Enum.chunk_every(@part_size) | |
| 28 | |> Enum.intersperse("-") | |
| 29 | 20 | |> List.to_string() |
| 30 | end | |
| 31 | ||
| 32 | def verify(recovery_codes, code_str) do | |
| 33 | 1 | case find_valid_code(recovery_codes, code_str) do |
| 34 | 1 | %RecoveryCode{code: ^code_str} = code -> {:ok, code} |
| 35 | 0 | nil -> {:error, :invalid_code} |
| 36 | end | |
| 37 | end | |
| 38 | ||
| 39 | defp find_valid_code(recovery_codes, code_str) do | |
| 40 | 1 | Enum.find(recovery_codes, fn rc -> |
| 41 | 1 | is_nil(rc.used_at) and Plug.Crypto.secure_compare(code_str, rc.code) |
| 42 | end) | |
| 43 | end | |
| 44 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Accounts.Session do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | 187 | schema "sessions" do |
| 4 | field :token, :binary | |
| 5 | field :data, :map | |
| 6 | timestamps() | |
| 7 | end | |
| 8 | ||
| 9 | def build(data) do | |
| 10 | 187 | change(%Session{}, data: data, token: :crypto.strong_rand_bytes(96)) |
| 11 | end | |
| 12 | ||
| 13 | def update(session, data) do | |
| 14 | 0 | change(session, data: data) |
| 15 | end | |
| 16 | ||
| 17 | def by_id(query \\ __MODULE__, id) do | |
| 18 | 4 | from(s in query, where: [id: ^id]) |
| 19 | end | |
| 20 | ||
| 21 | def by_user(query \\ __MODULE__, user) do | |
| 22 | 1 | from(s in query, where: fragment("(?->>'user_id')::integer", s.data) == ^user.id) |
| 23 | end | |
| 24 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Accounts.TFA do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | @primary_key false | |
| 4 | 63 | embedded_schema do |
| 5 | field :secret, :string | |
| 6 | field :tfa_enabled, :boolean, default: false | |
| 7 | field :app_enabled, :boolean, default: false | |
| 8 | embeds_many :recovery_codes, Hexpm.Accounts.RecoveryCode | |
| 9 | end | |
| 10 | ||
| 11 | def changeset(tfa, params) do | |
| 12 | tfa | |
| 13 | |> cast(params, ~w(secret app_enabled tfa_enabled)a) | |
| 14 | 0 | |> cast_embed(:recovery_codes) |
| 15 | end | |
| 16 | ||
| 17 | def generate_secret() do | |
| 18 | 10 | |
| 19 | |> :crypto.strong_rand_bytes() | |
| 20 | 2 | |> Base.encode32() |
| 21 | end | |
| 22 | ||
| 23 | # addwindow 1 creates a token 30 seconds ahead | |
| 24 | def time_based_token(secret) do | |
| 25 | 2 | :pot.totp(secret, addwindow: 1) |
| 26 | end | |
| 27 | ||
| 28 | # Check a token 30 seconds ahead and within a margin of error of 1 second | |
| 29 | def token_valid?(secret, token) do | |
| 30 | 4 | :pot.valid_totp(token, secret, window: 1, addwindow: 1) |
| 31 | end | |
| 32 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Accounts.User do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | @derive {HexpmWeb.Stale, assocs: [:emails, :owned_packages, :organizations, :keys]} | |
| 4 | @derive {Phoenix.Param, key: :username} | |
| 5 | ||
| 6 | alias Hexpm.Accounts.{RecoveryCode, TFA} | |
| 7 | ||
| 8 | 8197 | schema "users" do |
| 9 | field :username, :string | |
| 10 | field :full_name, :string | |
| 11 | field :password, :string | |
| 12 | field :service, :boolean, default: false | |
| 13 | field :deactivated_at, :utc_datetime_usec | |
| 14 | field :role, :string, default: "basic" | |
| 15 | timestamps() | |
| 16 | ||
| 17 | embeds_one :handles, UserHandles, on_replace: :delete | |
| 18 | embeds_one :tfa, TFA, on_replace: :delete | |
| 19 | ||
| 20 | belongs_to :organization, Organization | |
| 21 | has_many :emails, Email | |
| 22 | has_many :package_owners, PackageOwner | |
| 23 | has_many :owned_packages, through: [:package_owners, :package] | |
| 24 | has_many :organization_users, OrganizationUser | |
| 25 | has_many :organizations, through: [:organization_users, :organization] | |
| 26 | has_many :keys, Key | |
| 27 | has_many :audit_logs, AuditLog | |
| 28 | has_many :password_resets, PasswordReset | |
| 29 | has_many :package_reports, Hexpm.Repository.PackageReport, foreign_key: :author_id | |
| 30 | end | |
| 31 | ||
| 32 | @username_regex ~r"^[a-z0-9_\-\.]+$" | |
| 33 | @username_reject_regex ~r"(?!kneergo)$" | |
| 34 | @reserved_names ~w(me hex hexpm elixir erlang otp) | |
| 35 | @possible_roles ~w(basic mod) | |
| 36 | ||
| 37 | def build(params, confirmed? \\ not Application.get_env(:hexpm, :user_confirm)) do | |
| 38 | cast(%User{}, params, ~w(username full_name password)a) | |
| 39 | |> validate_required(~w(username password)a) | |
| 40 | 9 | |> cast_assoc(:emails, required: true, with: &Email.changeset(&1, :first, &2, confirmed?)) |
| 41 | |> cast_embed(:tfa) | |
| 42 | |> update_change(:username, &String.downcase/1) | |
| 43 | |> validate_length(:username, min: 3) | |
| 44 | |> validate_format(:username, @username_regex) | |
| 45 | |> validate_format(:username, @username_reject_regex) | |
| 46 | |> validate_exclusion(:username, @reserved_names) | |
| 47 | |> unique_constraint(:username, name: "users_username_idx") | |
| 48 | |> validate_length(:password, min: 7) | |
| 49 | |> validate_confirmation(:password, message: "does not match password") | |
| 50 | 13 | |> update_change(:password, &Auth.gen_password/1) |
| 51 | end | |
| 52 | ||
| 53 | def build_organization(organization) do | |
| 54 | 1 | username = organization_name(organization) |
| 55 | ||
| 56 | 1 | change(%User{username: username, organization_id: organization.id}, %{}) |
| 57 | |> update_change(:username, &String.downcase/1) | |
| 58 | |> validate_length(:username, min: 3) | |
| 59 | |> validate_format(:username, @username_regex) | |
| 60 | |> validate_exclusion(:username, @reserved_names) | |
| 61 | 1 | |> unique_constraint(:username, name: "users_username_idx") |
| 62 | end | |
| 63 | ||
| 64 | def to_organization(user, organization) do | |
| 65 | 0 | change(user, %{password: nil, organization_id: organization.id}) |
| 66 | end | |
| 67 | ||
| 68 | def update_profile(user, params) do | |
| 69 | cast(user, params, ~w(full_name)a) | |
| 70 | 34 | |> cast_embed(:handles) |
| 71 | end | |
| 72 | ||
| 73 | def update_password_no_check(user, params) do | |
| 74 | cast(user, params, ~w(password)a) | |
| 75 | |> validate_required(~w(password)a) | |
| 76 | |> validate_length(:password, min: 7) | |
| 77 | |> validate_confirmation(:password, message: "does not match password") | |
| 78 | 4 | |> update_change(:password, &Auth.gen_password/1) |
| 79 | end | |
| 80 | ||
| 81 | def update_password(user, params) do | |
| 82 | 6 | password = user.password |
| 83 | 6 | user = %{user | password: nil} |
| 84 | ||
| 85 | cast(user, params, ~w(password)a) | |
| 86 | |> validate_required(~w(password)a) | |
| 87 | |> validate_length(:password, min: 7) | |
| 88 | |> validate_password(:password, password) | |
| 89 | |> validate_confirmation(:password, message: "does not match password") | |
| 90 | 6 | |> update_change(:password, &Auth.gen_password/1) |
| 91 | end | |
| 92 | ||
| 93 | def can_reset_password?(user, key) do | |
| 94 | 7 | primary_email = email(user, :primary) |
| 95 | ||
| 96 | 7 | Enum.any?(user.password_resets, fn reset -> |
| 97 | 9 | PasswordReset.can_reset?(reset, primary_email, key) |
| 98 | end) | |
| 99 | end | |
| 100 | ||
| 101 | def set_role(user, params) do | |
| 102 | cast(user, params, ~w(role)a) | |
| 103 | |> validate_required(~w(role)a) | |
| 104 | 0 | |> validate_inclusion(:role, @possible_roles) |
| 105 | end | |
| 106 | ||
| 107 | 126 | def email(user, :primary), do: user.emails |> Enum.find(& &1.primary) |> email() |
| 108 | 48 | def email(user, :public), do: user.emails |> Enum.find(& &1.public) |> email() |
| 109 | 100 | def email(user, :gravatar), do: user.emails |> Enum.find(& &1.gravatar) |> email() |
| 110 | ||
| 111 | 2 | defp email(nil), do: nil |
| 112 | 272 | defp email(email), do: email.email |
| 113 | ||
| 114 | def get(username_or_email, preload \\ []) do | |
| 115 | 63 | from( |
| 116 | u in Hexpm.Accounts.User, | |
| 117 | where: | |
| 118 | u.username == ^username_or_email or | |
| 119 | ^username_or_email in fragment( | |
| 120 | "SELECT emails.email FROM emails WHERE emails.user_id = ? and emails.verified", | |
| 121 | u.id | |
| 122 | ), | |
| 123 | preload: ^preload | |
| 124 | ) | |
| 125 | end | |
| 126 | ||
| 127 | def public_get(username_or_email, preload \\ []) do | |
| 128 | 38 | from( |
| 129 | u in Hexpm.Accounts.User, | |
| 130 | where: | |
| 131 | u.username == ^username_or_email or | |
| 132 | ^username_or_email in fragment( | |
| 133 | "SELECT emails.email FROM emails WHERE emails.user_id = ? and emails.verified and emails.public", | |
| 134 | u.id | |
| 135 | ), | |
| 136 | preload: ^preload | |
| 137 | ) | |
| 138 | end | |
| 139 | ||
| 140 | def get_by_role(role, preload \\ []) do | |
| 141 | 15 | from( |
| 142 | u in Hexpm.Accounts.User, | |
| 143 | where: u.role == ^role, | |
| 144 | preload: ^preload | |
| 145 | ) | |
| 146 | end | |
| 147 | ||
| 148 | 8 | def verify_permissions(%User{}, "api", _resource) do |
| 149 | {:ok, nil} | |
| 150 | end | |
| 151 | ||
| 152 | 1 | def verify_permissions(%User{}, "repositories", nil) do |
| 153 | {:ok, nil} | |
| 154 | end | |
| 155 | ||
| 156 | 0 | def verify_permissions(%User{}, "repository", nil) do |
| 157 | :error | |
| 158 | end | |
| 159 | ||
| 160 | def verify_permissions(%User{} = user, domain, name) when domain in ["repository", "docs"] do | |
| 161 | 13 | organization = Organizations.get(name) |
| 162 | ||
| 163 | 13 | if organization && Organizations.access?(organization, user, "read") do |
| 164 | {:ok, organization} | |
| 165 | else | |
| 166 | :error | |
| 167 | end | |
| 168 | end | |
| 169 | ||
| 170 | 0 | def verify_permissions(%User{}, _domain, _resource) do |
| 171 | :error | |
| 172 | end | |
| 173 | ||
| 174 | 285 | def organization?(user), do: user.organization_id != nil |
| 175 | ||
| 176 | # Workaround for compatibility with older Hex client tests, fixed in Hex v0.20.1 | |
| 177 | if Mix.env() == :hex do | |
| 178 | defp organization_name(organization), do: organization.name <> "-orguser" | |
| 179 | else | |
| 180 | 1 | defp organization_name(organization), do: organization.name |
| 181 | end | |
| 182 | ||
| 183 | 0 | def tfa_enabled?(%{tfa: nil}), do: false |
| 184 | 5 | def tfa_enabled?(%{tfa: %{tfa_enabled: true}}), do: true |
| 185 | 0 | def tfa_enabled?(%{tfa: %{tfa_enabled: _value}}), do: false |
| 186 | ||
| 187 | def update_tfa(user, changes) do | |
| 188 | 6 | current_tfa = user.tfa || %{} |
| 189 | 6 | put_embed(change(user, %{}), :tfa, Map.merge(current_tfa, changes)) |
| 190 | end | |
| 191 | ||
| 192 | def recovery_code_used(user, code) do | |
| 193 | 1 | codes = Enum.map(user.tfa.recovery_codes, &use_recovery_code(&1, code)) |
| 194 | 1 | update_tfa(user, %{recovery_codes: codes}) |
| 195 | end | |
| 196 | ||
| 197 | def rotate_recovery_codes(user) do | |
| 198 | 1 | codes = Hexpm.Accounts.RecoveryCode.generate_set() |
| 199 | 1 | update_tfa(user, %{recovery_codes: codes}) |
| 200 | end | |
| 201 | ||
| 202 | defp use_recovery_code(%RecoveryCode{code: code_str}, %RecoveryCode{code: code_str} = code) do | |
| 203 | 1 | %{code | used_at: DateTime.utc_now()} |
| 204 | end | |
| 205 | ||
| 206 | 1 | defp use_recovery_code(code, _other), do: code |
| 207 | ||
| 208 | def has_role?(user, role) do | |
| 209 | 41 | user != nil and user.role == role |
| 210 | end | |
| 211 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Accounts.UserHandles do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | @derive HexpmWeb.Stale | |
| 4 | ||
| 5 | 703 | embedded_schema do |
| 6 | field :twitter, :string | |
| 7 | field :github, :string | |
| 8 | field :elixirforum, :string | |
| 9 | field :freenode, :string | |
| 10 | field :slack, :string | |
| 11 | end | |
| 12 | ||
| 13 | def changeset(handles, params) do | |
| 14 | 12 | cast(handles, params, ~w(twitter github elixirforum freenode slack)a) |
| 15 | end | |
| 16 | ||
| 17 | 24 | def services() do |
| 18 | [ | |
| 19 | {:twitter, "Twitter", "https://twitter.com/{handle}"}, | |
| 20 | {:github, "GitHub", "https://github.com/{handle}"}, | |
| 21 | {:elixirforum, "Elixir Forum", "https://elixirforum.com/u/{handle}"}, | |
| 22 | {:freenode, "Libera", "irc://irc.libera.chat/elixir"}, | |
| 23 | {:slack, "Slack", "https://elixir-slackin.herokuapp.com/"} | |
| 24 | ] | |
| 25 | end | |
| 26 | ||
| 27 | 3 | def render(%{handles: nil}) do |
| 28 | [] | |
| 29 | end | |
| 30 | ||
| 31 | def render(user) do | |
| 32 | 24 | Enum.flat_map(services(), fn {field, service, url} -> |
| 33 | 120 | handle = Map.get(user.handles, field) |
| 34 | ||
| 35 | 120 | if handle = handle && handle(field, handle) do |
| 36 | 15 | full_url = String.replace(url, "{handle}", handle) |
| 37 | [{service, handle, full_url}] | |
| 38 | else | |
| 39 | [] | |
| 40 | end | |
| 41 | end) | |
| 42 | end | |
| 43 | ||
| 44 | 4 | def handle(:twitter, handle), do: unuri(handle, "twitter.com", "/") |
| 45 | 3 | def handle(:github, handle), do: unuri(handle, "github.com", "/") |
| 46 | 3 | def handle(:elixirforum, handle), do: unuri(handle, "elixirforum.com", "/u/") |
| 47 | 6 | def handle(_service, handle), do: handle |
| 48 | ||
| 49 | defp unuri(handle, host, path) do | |
| 50 | 10 | uri = URI.parse(handle) |
| 51 | 10 | http? = uri.scheme in ["http", "https"] |
| 52 | 10 | host? = String.contains?(uri.host || "", host) |
| 53 | 10 | path? = String.starts_with?(uri.path || "", path) |
| 54 | ||
| 55 | 10 | cond do |
| 56 | 10 | http? and host? and path? -> |
| 57 | 6 | {_, handle} = String.split_at(uri.path, String.length(path)) |
| 58 | 6 | handle |
| 59 | ||
| 60 | 4 | uri.path -> |
| 61 | 3 | String.replace(uri.path, host <> path, "") |
| 62 | ||
| 63 | 1 | true -> |
| 64 | nil | |
| 65 | end | |
| 66 | end | |
| 67 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Accounts.Users do | |
| 1 | use Hexpm.Context | |
| 2 | ||
| 3 | alias Hexpm.Accounts.{RecoveryCode, TFA} | |
| 4 | ||
| 5 | def get(username_or_email, preload \\ []) do | |
| 6 | User.get(String.downcase(username_or_email), preload) | |
| 7 | 60 | |> Repo.one() |
| 8 | end | |
| 9 | ||
| 10 | def public_get(username_or_email, preload \\ []) do | |
| 11 | User.public_get(String.downcase(username_or_email), preload) | |
| 12 | 36 | |> Repo.one() |
| 13 | end | |
| 14 | ||
| 15 | def get_by_id(id, preload \\ []) do | |
| 16 | Repo.get(User, id) | |
| 17 | 134 | |> Repo.preload(preload) |
| 18 | end | |
| 19 | ||
| 20 | def get_by_username(username, preload \\ []) do | |
| 21 | Repo.get_by(User, username: String.downcase(username)) | |
| 22 | 4 | |> Repo.preload(preload) |
| 23 | end | |
| 24 | ||
| 25 | def get_by_role(role, preload \\ []) do | |
| 26 | User.get_by_role(String.downcase(role)) | |
| 27 | |> Repo.all() | |
| 28 | 15 | |> Repo.preload(preload) |
| 29 | end | |
| 30 | ||
| 31 | def get_email(email, preload \\ []) do | |
| 32 | Repo.get_by(Email, email: String.downcase(email)) | |
| 33 | 9 | |> Repo.preload(preload) |
| 34 | end | |
| 35 | ||
| 36 | 16 | def all_organizations(%User{organizations: organizations}) when is_list(organizations) do |
| 37 | [Organization.hexpm() | organizations] | |
| 38 | end | |
| 39 | ||
| 40 | 22 | def all_organizations(nil) do |
| 41 | [Organization.hexpm()] | |
| 42 | end | |
| 43 | ||
| 44 | def add(params, audit: audit_data) do | |
| 45 | 6 | multi = |
| 46 | Multi.new() | |
| 47 | |> Multi.insert(:user, User.build(params)) | |
| 48 | 4 | |> audit_with_user(audit_data, "user.create", fn %{user: user} -> user end) |
| 49 | 4 | |> audit_with_user(audit_data, "email.add", fn %{user: %{emails: [email]}} -> email end) |
| 50 | 4 | |> audit_with_user(audit_data, "email.primary", fn %{user: %{emails: [email]}} -> |
| 51 | {nil, email} | |
| 52 | end) | |
| 53 | 4 | |> audit_with_user(audit_data, "email.public", fn %{user: %{emails: [email]}} -> |
| 54 | {nil, email} | |
| 55 | end) | |
| 56 | ||
| 57 | 6 | case Repo.transaction(multi) do |
| 58 | {:ok, %{user: %{emails: [email]} = user}} -> | |
| 59 | Emails.verification(user, email) | |
| 60 | 4 | |> Mailer.deliver_later!() |
| 61 | ||
| 62 | {:ok, user} | |
| 63 | ||
| 64 | 2 | {:error, :user, changeset, _} -> |
| 65 | {:error, changeset} | |
| 66 | end | |
| 67 | end | |
| 68 | ||
| 69 | def email_verification(%User{organization_id: id}, email) when not is_nil(id) do | |
| 70 | 0 | |
| 71 | end | |
| 72 | ||
| 73 | def email_verification(user, email) do | |
| 74 | 1 | email = |
| 75 | Email.verification(email) | |
| 76 | |> Repo.update!() | |
| 77 | ||
| 78 | Emails.verification(user, email) | |
| 79 | 1 | |> Mailer.deliver_later!() |
| 80 | ||
| 81 | 1 | |
| 82 | end | |
| 83 | ||
| 84 | def update_profile(%User{organization_id: id} = user, params, audit: audit_data) | |
| 85 | when not is_nil(id) do | |
| 86 | 16 | multi = |
| 87 | Multi.new() | |
| 88 | |> Multi.update(:user, User.update_profile(user, params)) | |
| 89 | 13 | |> audit(audit_data, "user.update", fn %{user: user} -> user end) |
| 90 | |> insert_or_update_or_delete_email_multi(user, :public, params["public_email"], | |
| 91 | audit: audit_data | |
| 92 | ) | |
| 93 | |> insert_or_update_or_delete_email_multi(user, :gravatar, params["gravatar_email"], | |
| 94 | audit: audit_data | |
| 95 | ) | |
| 96 | ||
| 97 | 16 | case Repo.transaction(multi) do |
| 98 | 13 | {:ok, %{user: user}} -> |
| 99 | {:ok, user} | |
| 100 | ||
| 101 | 2 | {:error, :public_email, _, _} -> |
| 102 | {:error, | |
| 103 | %Ecto.Changeset{data: user, errors: [public_email: {"unknown error", []}], valid?: false}} | |
| 104 | ||
| 105 | 1 | {:error, :gravatar_email, _, _} -> |
| 106 | {:error, | |
| 107 | %Ecto.Changeset{ | |
| 108 | data: user, | |
| 109 | errors: [gravatar_email: {"unknown error", []}], | |
| 110 | valid?: false | |
| 111 | }} | |
| 112 | end | |
| 113 | end | |
| 114 | ||
| 115 | def update_profile(user, params, audit: audit_data) do | |
| 116 | 5 | multi = |
| 117 | Multi.new() | |
| 118 | |> Multi.update(:user, User.update_profile(user, params)) | |
| 119 | 5 | |> audit(audit_data, "user.update", fn %{user: user} -> user end) |
| 120 | |> public_email_multi(user, %{"email" => params["public_email"]}, audit: audit_data) | |
| 121 | |> gravatar_email_multi(user, %{"email" => params["gravatar_email"]}, audit: audit_data) | |
| 122 | ||
| 123 | 5 | case Repo.transaction(multi) do |
| 124 | 5 | {:ok, %{user: user}} -> |
| 125 | {:ok, user} | |
| 126 | ||
| 127 | 0 | {:error, :public_email, _, _} -> |
| 128 | {:error, | |
| 129 | %Ecto.Changeset{data: user, errors: [public_email: {"unknown error", []}], valid?: false}} | |
| 130 | ||
| 131 | 0 | {:error, :gravatar_email, _, _} -> |
| 132 | {:error, | |
| 133 | %Ecto.Changeset{ | |
| 134 | data: user, | |
| 135 | errors: [gravatar_email: {"unknown error", []}], | |
| 136 | valid?: false | |
| 137 | }} | |
| 138 | end | |
| 139 | end | |
| 140 | ||
| 141 | def update_password(%User{organization_id: id} = user, _params, _opts) when not is_nil(id) do | |
| 142 | 0 | organization_error(user, "cannot change password of organizations") |
| 143 | end | |
| 144 | ||
| 145 | def update_password(user, params, audit: audit_data) do | |
| 146 | 4 | multi = |
| 147 | Multi.new() | |
| 148 | |> Multi.update(:user, User.update_password(user, params)) | |
| 149 | |> audit(audit_data, "password.update", nil) | |
| 150 | ||
| 151 | 4 | case Repo.transaction(multi) do |
| 152 | {:ok, %{user: user}} -> | |
| 153 | user | |
| 154 | |> Emails.password_changed() | |
| 155 | 1 | |> Mailer.deliver_later!() |
| 156 | ||
| 157 | {:ok, user} | |
| 158 | ||
| 159 | 3 | {:error, :user, changeset, _} -> |
| 160 | {:error, changeset} | |
| 161 | end | |
| 162 | end | |
| 163 | ||
| 164 | def tfa_enable(user, audit: audit_data) do | |
| 165 | 1 | secret = Hexpm.Accounts.TFA.generate_secret() |
| 166 | 1 | codes = Hexpm.Accounts.RecoveryCode.generate_set() |
| 167 | ||
| 168 | 1 | multi = |
| 169 | Multi.new() | |
| 170 | |> Multi.update( | |
| 171 | :user, | |
| 172 | User.update_tfa(user, %{tfa_enabled: true, secret: secret, recovery_codes: codes}) | |
| 173 | ) | |
| 174 | 1 | |> audit(audit_data, "security.update", fn %{user: user} -> user end) |
| 175 | ||
| 176 | 1 | {:ok, _} = Repo.transaction(multi) |
| 177 | end | |
| 178 | ||
| 179 | def tfa_disable(user, audit: audit_data) do | |
| 180 | 1 | multi = |
| 181 | Multi.new() | |
| 182 | |> Multi.update( | |
| 183 | :user, | |
| 184 | User.update_tfa(user, %{tfa_enabled: false, secret: nil, recovery_codes: []}) | |
| 185 | ) | |
| 186 | 1 | |> audit(audit_data, "security.update", fn %{user: user} -> user end) |
| 187 | ||
| 188 | 1 | {:ok, %{user: user}} = Repo.transaction(multi) |
| 189 | 1 | user |
| 190 | end | |
| 191 | ||
| 192 | def tfa_enable_app(user, verification_code, audit: audit_data) do | |
| 193 | 2 | if TFA.token_valid?(user.tfa.secret, verification_code) do |
| 194 | 1 | multi = |
| 195 | Multi.new() | |
| 196 | |> Multi.update(:user, User.update_tfa(user, %{app_enabled: true})) | |
| 197 | 1 | |> audit(audit_data, "security.update", fn %{user: user} -> user end) |
| 198 | ||
| 199 | 1 | {:ok, %{user: user}} = Repo.transaction(multi) |
| 200 | {:ok, user} | |
| 201 | else | |
| 202 | :error | |
| 203 | end | |
| 204 | end | |
| 205 | ||
| 206 | def tfa_disable_app(user, audit: audit_data) do | |
| 207 | 1 | secret = Hexpm.Accounts.TFA.generate_secret() |
| 208 | ||
| 209 | 1 | multi = |
| 210 | Multi.new() | |
| 211 | |> Multi.update(:user, User.update_tfa(user, %{app_enabled: false, secret: secret})) | |
| 212 | 1 | |> audit(audit_data, "security.update", fn %{user: user} -> user end) |
| 213 | ||
| 214 | 1 | {:ok, %{user: user}} = Repo.transaction(multi) |
| 215 | 1 | user |
| 216 | end | |
| 217 | ||
| 218 | def tfa_rotate_recovery_codes(user, audit: audit_data) do | |
| 219 | 1 | multi = |
| 220 | Multi.new() | |
| 221 | |> Multi.update(:user, User.rotate_recovery_codes(user)) | |
| 222 | 1 | |> audit(audit_data, "security.rotate_recovery_codes", fn %{user: user} -> user end) |
| 223 | ||
| 224 | 1 | {:ok, %{user: user}} = Repo.transaction(multi) |
| 225 | 1 | user |
| 226 | end | |
| 227 | ||
| 228 | def verify_email(username, email, key) do | |
| 229 | 6 | with %User{organization_id: nil, emails: emails} <- get(username, :emails), |
| 230 | 5 | %Email{} = email <- Enum.find(emails, &(&1.email == email)), |
| 231 | 5 | true <- Email.verify?(email, key), |
| 232 | 3 | {:ok, _} <- Email.verify(email) |> Repo.update() do |
| 233 | :ok | |
| 234 | else | |
| 235 | _ -> :error | |
| 236 | end | |
| 237 | end | |
| 238 | ||
| 239 | def password_reset_init(name, audit: audit_data) do | |
| 240 | 7 | user = get(name, [:emails]) |
| 241 | ||
| 242 | 7 | if user && !User.organization?(user) do |
| 243 | 7 | changeset = PasswordReset.changeset(build_assoc(user, :password_resets), user) |
| 244 | ||
| 245 | 7 | {:ok, %{reset: reset}} = |
| 246 | Multi.new() | |
| 247 | |> Multi.insert(:reset, changeset) | |
| 248 | |> audit(audit_data, "password.reset.init", nil) | |
| 249 | |> Repo.transaction() | |
| 250 | ||
| 251 | Emails.password_reset_request(user, reset) | |
| 252 | 7 | |> Mailer.deliver_later!() |
| 253 | ||
| 254 | :ok | |
| 255 | else | |
| 256 | {:error, :not_found} | |
| 257 | end | |
| 258 | end | |
| 259 | ||
| 260 | def password_reset_finish(username, key, params, revoke_all_keys?, audit: audit_data) do | |
| 261 | 3 | user = get(username, [:emails, :password_resets]) |
| 262 | ||
| 263 | 3 | if user && !User.organization?(user) && User.can_reset_password?(user, key) do |
| 264 | 1 | multi = |
| 265 | password_reset(user, params, revoke_all_keys?) | |
| 266 | |> audit(audit_data, "password.reset.finish", nil) | |
| 267 | ||
| 268 | 1 | case Repo.transaction(multi) do |
| 269 | 1 | {:ok, _} -> |
| 270 | :ok | |
| 271 | ||
| 272 | 0 | {:error, _, changeset, _} -> |
| 273 | {:error, changeset} | |
| 274 | end | |
| 275 | else | |
| 276 | :error | |
| 277 | end | |
| 278 | end | |
| 279 | ||
| 280 | defp password_reset(user, params, revoke_all_keys) do | |
| 281 | 1 | multi = |
| 282 | Multi.new() | |
| 283 | |> Multi.update(:password, User.update_password_no_check(user, params)) | |
| 284 | |> Multi.delete_all(:reset, assoc(user, :password_resets)) | |
| 285 | |> Multi.delete_all(:reset_sessions, Session.by_user(user)) | |
| 286 | ||
| 287 | 1 | if revoke_all_keys, |
| 288 | 1 | do: Multi.update_all(multi, :keys, Key.revoke_all(user), []), |
| 289 | 0 | else: multi |
| 290 | end | |
| 291 | ||
| 292 | def add_email(%User{organization_id: id} = user, _params, _opts) when not is_nil(id) do | |
| 293 | 0 | organization_error(user, "cannot add email to organizations") |
| 294 | end | |
| 295 | ||
| 296 | def add_email(user, params, audit: audit_data) do | |
| 297 | 15 | email = build_assoc(user, :emails) |
| 298 | ||
| 299 | 15 | multi = |
| 300 | Multi.new() | |
| 301 | |> Multi.insert(:email, Email.changeset(email, :create, params)) | |
| 302 | 14 | |> audit(audit_data, "email.add", fn %{email: email} -> email end) |
| 303 | ||
| 304 | 15 | case Repo.transaction(multi) do |
| 305 | {:ok, %{email: email}} -> | |
| 306 | 14 | user = Repo.preload(user, :emails, force: true) |
| 307 | ||
| 308 | Emails.verification(user, email) | |
| 309 | 14 | |> Mailer.deliver_later!() |
| 310 | ||
| 311 | {:ok, user} | |
| 312 | ||
| 313 | 1 | {:error, :email, changeset, _} -> |
| 314 | {:error, changeset} | |
| 315 | end | |
| 316 | end | |
| 317 | ||
| 318 | def remove_email(%User{organization_id: id} = user, _params, _opts) when not is_nil(id) do | |
| 319 | 0 | organization_error(user, "cannot remove email of organizations") |
| 320 | end | |
| 321 | ||
| 322 | def remove_email(user, params, audit: audit_data) do | |
| 323 | 2 | email = find_email(user, params) |
| 324 | ||
| 325 | 2 | cond do |
| 326 | 2 | !email -> |
| 327 | {:error, :unknown_email} | |
| 328 | ||
| 329 | 2 | email.primary -> |
| 330 | {:error, :primary} | |
| 331 | ||
| 332 | 1 | true -> |
| 333 | 1 | {:ok, _} = |
| 334 | Multi.new() | |
| 335 | |> Ecto.Multi.delete(:email, email) | |
| 336 | |> audit(audit_data, "email.remove", email) | |
| 337 | |> Repo.transaction() | |
| 338 | ||
| 339 | :ok | |
| 340 | end | |
| 341 | end | |
| 342 | ||
| 343 | def primary_email(%User{organization_id: id} = user, _params, _opts) when not is_nil(id) do | |
| 344 | 0 | organization_error(user, "cannot set email of organizations") |
| 345 | end | |
| 346 | ||
| 347 | def primary_email(user, params, opts) do | |
| 348 | 3 | multi = |
| 349 | Multi.new() | |
| 350 | |> email_flag_multi(user, params, :primary, opts) | |
| 351 | |> Multi.delete_all(:reset, assoc(user, :password_resets)) | |
| 352 | ||
| 353 | 3 | case Repo.transaction(multi) do |
| 354 | 2 | {:ok, _} -> :ok |
| 355 | 1 | {:error, :primary_email, reason, _} -> {:error, reason} |
| 356 | end | |
| 357 | end | |
| 358 | ||
| 359 | def gravatar_email(%User{organization_id: id} = user, _params, _opts) when not is_nil(id) do | |
| 360 | 0 | organization_error(user, "cannot set email of organizations") |
| 361 | end | |
| 362 | ||
| 363 | def gravatar_email(user, params, opts) do | |
| 364 | 3 | multi = gravatar_email_multi(Multi.new(), user, params, opts) |
| 365 | ||
| 366 | 3 | case Repo.transaction(multi) do |
| 367 | 1 | {:ok, _} -> :ok |
| 368 | 2 | {:error, :gravatar_email, reason, _} -> {:error, reason} |
| 369 | end | |
| 370 | end | |
| 371 | ||
| 372 | defp gravatar_email_multi(multi, user, %{"email" => "none"}, opts) do | |
| 373 | 0 | unset_email_flag_multi(multi, user, :gravatar, opts) |
| 374 | end | |
| 375 | ||
| 376 | defp gravatar_email_multi(multi, user, params, opts) do | |
| 377 | 8 | email_flag_multi(multi, user, params, :gravatar, opts) |
| 378 | end | |
| 379 | ||
| 380 | def public_email(%User{organization_id: id} = user, _params, _opts) when not is_nil(id) do | |
| 381 | 0 | organization_error(user, "cannot set email of organizations") |
| 382 | end | |
| 383 | ||
| 384 | def public_email(user, params, opts) do | |
| 385 | 2 | multi = public_email_multi(Multi.new(), user, params, opts) |
| 386 | ||
| 387 | 2 | case Repo.transaction(multi) do |
| 388 | 2 | {:ok, _} -> :ok |
| 389 | 0 | {:error, :public_email, reason, _} -> {:error, reason} |
| 390 | end | |
| 391 | end | |
| 392 | ||
| 393 | defp public_email_multi(multi, user, %{"email" => "none"}, opts) do | |
| 394 | 3 | unset_email_flag_multi(multi, user, :public, opts) |
| 395 | end | |
| 396 | ||
| 397 | defp public_email_multi(multi, user, params, opts) do | |
| 398 | 4 | email_flag_multi(multi, user, params, :public, opts) |
| 399 | end | |
| 400 | ||
| 401 | defp unset_email_flag_multi(multi, user, flag, audit: audit_data) do | |
| 402 | 3 | if old_email = Enum.find(user.emails, &Map.get(&1, flag)) do |
| 403 | 2 | old_email_op = String.to_atom("old_#{flag}") |
| 404 | ||
| 405 | multi | |
| 406 | |> Multi.update(old_email_op, Email.toggle_flag(old_email, flag, false)) | |
| 407 | 2 | |> audit(audit_data, "email.#{flag}", {old_email, nil}) |
| 408 | else | |
| 409 | 1 | multi |
| 410 | end | |
| 411 | end | |
| 412 | ||
| 413 | defp email_flag_multi(multi, _user, %{"email" => nil}, _flag, _opts) do | |
| 414 | 6 | multi |
| 415 | end | |
| 416 | ||
| 417 | defp email_flag_multi(multi, user, params, flag, audit: audit_data) do | |
| 418 | 9 | new_email = find_email(user, params) |
| 419 | 9 | old_email = Enum.find(user.emails, &Map.get(&1, flag)) |
| 420 | 9 | error_op_name = String.to_atom("#{flag}_email") |
| 421 | ||
| 422 | 9 | cond do |
| 423 | 9 | !new_email -> |
| 424 | 1 | Multi.error(multi, error_op_name, :unknown_email) |
| 425 | ||
| 426 | 8 | !new_email.verified -> |
| 427 | 2 | Multi.error(multi, error_op_name, :not_verified) |
| 428 | ||
| 429 | 6 | old_email && new_email.id == old_email.id -> |
| 430 | 0 | multi |
| 431 | ||
| 432 | 6 | true -> |
| 433 | 6 | multi = |
| 434 | if old_email do | |
| 435 | 6 | old_email_op_name = String.to_atom("old_#{flag}") |
| 436 | 6 | toggle_changeset = Email.toggle_flag(old_email, flag, false) |
| 437 | 6 | Multi.update(multi, old_email_op_name, toggle_changeset) |
| 438 | else | |
| 439 | 0 | multi |
| 440 | end | |
| 441 | ||
| 442 | 6 | new_email_op_name = String.to_atom("new_#{flag}") |
| 443 | ||
| 444 | multi | |
| 445 | |> Multi.update(new_email_op_name, Email.toggle_flag(new_email, flag, true)) | |
| 446 | 6 | |> audit(audit_data, "email.#{flag}", {old_email, new_email}) |
| 447 | end | |
| 448 | end | |
| 449 | ||
| 450 | def insert_or_update_or_delete_email_multi(multi, _user, _flag, nil, _params) do | |
| 451 | 19 | multi |
| 452 | end | |
| 453 | ||
| 454 | def insert_or_update_or_delete_email_multi(multi, user, flag, "", audit: audit_data) do | |
| 455 | 4 | user = Repo.preload(user, :organization) |
| 456 | ||
| 457 | 4 | if old_email = Enum.find(user.emails, &Map.get(&1, flag)) do |
| 458 | 2 | email_op = String.to_atom("#{flag}_email") |
| 459 | ||
| 460 | multi | |
| 461 | |> Multi.delete(email_op, old_email) | |
| 462 | 2 | |> audit(audit_data, "email.remove", {user.organization, old_email}) |
| 463 | else | |
| 464 | 2 | multi |
| 465 | end | |
| 466 | end | |
| 467 | ||
| 468 | def insert_or_update_or_delete_email_multi(multi, user, flag, email_address, audit: audit_data) do | |
| 469 | 9 | email_op = String.to_atom("#{flag}_email") |
| 470 | 9 | user = Repo.preload(user, :organization) |
| 471 | ||
| 472 | 9 | if old_email = Enum.find(user.emails, &Map.get(&1, flag)) do |
| 473 | multi | |
| 474 | |> Multi.update(email_op, Email.update_email(old_email, email_address)) | |
| 475 | 3 | |> audit(audit_data, "email.#{flag}", fn %{^email_op => new_email} -> |
| 476 | 2 | {user.organization, {old_email, new_email}} |
| 477 | end) | |
| 478 | else | |
| 479 | multi | |
| 480 | |> Multi.insert( | |
| 481 | email_op, | |
| 482 | Email.changeset( | |
| 483 | build_assoc(user, :emails), | |
| 484 | :create_for_org, | |
| 485 | %{:email => email_address, flag => true}, | |
| 486 | false | |
| 487 | ) | |
| 488 | ) | |
| 489 | 4 | |> audit(audit_data, "email.add", fn %{^email_op => email} -> {user.organization, email} end) |
| 490 | 6 | |> audit(audit_data, "email.#{flag}", fn %{^email_op => email} -> |
| 491 | 4 | {user.organization, {nil, email}} |
| 492 | end) | |
| 493 | end | |
| 494 | end | |
| 495 | ||
| 496 | def resend_verify_email(user, params) do | |
| 497 | 1 | email = find_email(user, params) |
| 498 | ||
| 499 | 1 | cond do |
| 500 | 1 | !email -> |
| 501 | {:error, :unknown_email} | |
| 502 | ||
| 503 | 1 | email.verified -> |
| 504 | {:error, :already_verified} | |
| 505 | ||
| 506 | 1 | true -> |
| 507 | Emails.verification(user, email) | |
| 508 | 1 | |> Mailer.deliver_later!() |
| 509 | ||
| 510 | :ok | |
| 511 | end | |
| 512 | end | |
| 513 | ||
| 514 | def tfa_recover(%User{} = user, code_str) do | |
| 515 | 1 | case RecoveryCode.verify(user.tfa.recovery_codes, code_str) do |
| 516 | {:ok, %RecoveryCode{} = code} -> | |
| 517 | 1 | user = |
| 518 | user | |
| 519 | |> User.recovery_code_used(code) | |
| 520 | |> Repo.update!() | |
| 521 | ||
| 522 | {:ok, user} | |
| 523 | ||
| 524 | err -> | |
| 525 | 0 | err |
| 526 | end | |
| 527 | end | |
| 528 | ||
| 529 | defp find_email(user, params) do | |
| 530 | 12 | Enum.find(user.emails, &(&1.email == params["email"])) |
| 531 | end | |
| 532 | ||
| 533 | 0 | defp organization_error(user, message) do |
| 534 | {:error, | |
| 535 | %Ecto.Changeset{ | |
| 536 | data: user, | |
| 537 | errors: [organization: {message, []}], | |
| 538 | valid?: false | |
| 539 | }} | |
| 540 | end | |
| 541 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Application do | |
| 1 | use Application | |
| 2 | ||
| 3 | def start(_type, _args) do | |
| 4 | 1 | topologies = cluster_topologies() |
| 5 | 1 | read_only_mode() |
| 6 | 1 | Hexpm.BlockAddress.start() |
| 7 | ||
| 8 | 1 | children = [ |
| 9 | Hexpm.RepoBase, | |
| 10 | {Task.Supervisor, name: Hexpm.Tasks}, | |
| 11 | {Cluster.Supervisor, [topologies, [name: Hexpm.ClusterSupervisor]]}, | |
| 12 | {Phoenix.PubSub, name: Hexpm.PubSub, adapter: Phoenix.PubSub.PG2}, | |
| 13 | HexpmWeb.RateLimitPubSub, | |
| 14 | {PlugAttack.Storage.Ets, name: HexpmWeb.Plugs.Attack.Storage, clean_period: 60_000}, | |
| 15 | {Hexpm.Billing.Report, name: Hexpm.Billing.Report, interval: 60_000}, | |
| 16 | goth_spec(), | |
| 17 | HexpmWeb.Telemetry, | |
| 18 | HexpmWeb.Endpoint | |
| 19 | ] | |
| 20 | ||
| 21 | 1 | File.mkdir_p(Application.get_env(:hexpm, :tmp_dir)) |
| 22 | 1 | shutdown_on_eof() |
| 23 | ||
| 24 | 1 | opts = [strategy: :one_for_one, name: Hexpm.Supervisor] |
| 25 | 1 | Supervisor.start_link(children, opts) |
| 26 | end | |
| 27 | ||
| 28 | def config_change(changed, _new, removed) do | |
| 29 | 0 | HexpmWeb.Endpoint.config_change(changed, removed) |
| 30 | :ok | |
| 31 | end | |
| 32 | ||
| 33 | # Make sure we exit after hex client tests are finished running | |
| 34 | if Mix.env() == :hex do | |
| 35 | def shutdown_on_eof() do | |
| 36 | spawn_link(fn -> | |
| 37 | IO.gets(:stdio, '') == :eof && System.halt(0) | |
| 38 | end) | |
| 39 | end | |
| 40 | else | |
| 41 | 1 | def shutdown_on_eof(), do: nil |
| 42 | end | |
| 43 | ||
| 44 | defp read_only_mode() do | |
| 45 | 1 | mode = System.get_env("HEXPM_READ_ONLY_MODE") == "1" |
| 46 | 1 | Application.put_env(:hexpm, :read_only_mode, mode) |
| 47 | end | |
| 48 | ||
| 49 | defp cluster_topologies() do | |
| 50 | 1 | if System.get_env("HEXPM_CLUSTER") == "1" do |
| 51 | 0 | Application.get_env(:hexpm, :topologies) || [] |
| 52 | else | |
| 53 | [] | |
| 54 | end | |
| 55 | end | |
| 56 | ||
| 57 | if Mix.env() == :prod do | |
| 58 | defp goth_spec() do | |
| 59 | credentials = | |
| 60 | "HEXPM_GCP_CREDENTIALS" | |
| 61 | |> System.fetch_env!() | |
| 62 | |> Jason.decode!() | |
| 63 | ||
| 64 | options = [scope: "https://www.googleapis.com/auth/devstorage.read_write"] | |
| 65 | {Goth, name: Hexpm.Goth, source: {:service_account, credentials, options}} | |
| 66 | end | |
| 67 | else | |
| 68 | 1 | defp goth_spec() do |
| 69 | 1 | {Task, fn -> :ok end} |
| 70 | end | |
| 71 | end | |
| 72 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Billing do | |
| 1 | use Hexpm.Context | |
| 2 | ||
| 3 | @type organization() :: String.t() | |
| 4 | ||
| 5 | @callback checkout(organization(), data :: map()) :: {:ok, map()} | {:error, map()} | |
| 6 | @callback get(organization()) :: map() | nil | |
| 7 | @callback cancel(organization()) :: map() | |
| 8 | @callback create(map()) :: {:ok, map()} | {:error, map()} | |
| 9 | @callback update(organization(), map()) :: {:ok, map()} | {:error, map()} | |
| 10 | @callback change_plan(organization(), map()) :: :ok | |
| 11 | @callback invoice(id :: pos_integer()) :: binary() | |
| 12 | @callback pay_invoice(id :: pos_integer()) :: :ok | {:error, map()} | |
| 13 | @callback report() :: [map()] | |
| 14 | ||
| 15 | 64 | defp impl(), do: Application.get_env(:hexpm, :billing_impl) |
| 16 | ||
| 17 | 0 | def checkout(organization, data), do: impl().checkout(organization, data) |
| 18 | 22 | def get(organization), do: impl().get(organization) |
| 19 | 0 | def cancel(organization), do: impl().cancel(organization) |
| 20 | 0 | def create(params), do: impl().create(params) |
| 21 | 1 | def update(organization, params), do: impl().update(organization, params) |
| 22 | 0 | def change_plan(organization, params), do: impl().change_plan(organization, params) |
| 23 | 1 | def invoice(id), do: impl().invoice(id) |
| 24 | 0 | def pay_invoice(id), do: impl().pay_invoice(id) |
| 25 | 2 | def report(), do: impl().report() |
| 26 | ||
| 27 | @doc """ | |
| 28 | Change payment method used by an organization. | |
| 29 | """ | |
| 30 | def checkout(organization_name, data, | |
| 31 | audit: %{audit_data: audit_data, organization: organization} | |
| 32 | ) do | |
| 33 | 6 | case impl().checkout(organization_name, data) do |
| 34 | {:ok, body} -> | |
| 35 | 4 | Repo.insert!(audit(audit_data, "billing.checkout", {organization, data})) |
| 36 | {:ok, body} | |
| 37 | ||
| 38 | 2 | {:error, reason} -> |
| 39 | {:error, reason} | |
| 40 | end | |
| 41 | end | |
| 42 | ||
| 43 | def cancel(params, audit: %{audit_data: audit_data, organization: organization}) do | |
| 44 | 5 | result = impl().cancel(params) |
| 45 | 5 | Repo.insert!(audit(audit_data, "billing.cancel", {organization, params})) |
| 46 | 5 | result |
| 47 | end | |
| 48 | ||
| 49 | def create(params, audit: %{audit_data: audit_data, organization: organization}) do | |
| 50 | 6 | case impl().create(params) do |
| 51 | {:ok, result} -> | |
| 52 | 4 | Repo.insert!(audit(audit_data, "billing.create", {organization, params})) |
| 53 | {:ok, result} | |
| 54 | ||
| 55 | 2 | {:error, reason} -> |
| 56 | {:error, reason} | |
| 57 | end | |
| 58 | end | |
| 59 | ||
| 60 | def update(organization_name, params, | |
| 61 | audit: %{audit_data: audit_data, organization: organization} | |
| 62 | ) do | |
| 63 | 10 | case impl().update(organization_name, params) do |
| 64 | {:ok, result} -> | |
| 65 | 8 | Repo.insert!(audit(audit_data, "billing.update", {organization, params})) |
| 66 | {:ok, result} | |
| 67 | ||
| 68 | 2 | {:error, reason} -> |
| 69 | {:error, reason} | |
| 70 | end | |
| 71 | end | |
| 72 | ||
| 73 | def change_plan(organization_name, params, | |
| 74 | audit: %{audit_data: audit_data, organization: organization} | |
| 75 | ) do | |
| 76 | 4 | impl().change_plan(organization_name, params) |
| 77 | 4 | Repo.insert!(audit(audit_data, "billing.change_plan", {organization, params})) |
| 78 | :ok | |
| 79 | end | |
| 80 | ||
| 81 | def pay_invoice(id, audit: %{audit_data: audit_data, organization: organization}) do | |
| 82 | 7 | case impl().pay_invoice(id) do |
| 83 | :ok -> | |
| 84 | 4 | Repo.insert!(audit(audit_data, "billing.pay_invoice", {organization, id})) |
| 85 | :ok | |
| 86 | ||
| 87 | 3 | {:error, reason} -> |
| 88 | {:error, reason} | |
| 89 | end | |
| 90 | end | |
| 91 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Billing.Hexpm do | |
| 1 | @behaviour Hexpm.Billing | |
| 2 | ||
| 3 | @timeout 15_000 | |
| 4 | ||
| 5 | def checkout(organization, data) do | |
| 6 | 0 | case post("/api/customers/#{organization}/payment_source", data) do |
| 7 | 0 | {:ok, 204, _headers, body} -> {:ok, body} |
| 8 | 0 | {:ok, 422, _headers, body} -> {:error, body} |
| 9 | end | |
| 10 | end | |
| 11 | ||
| 12 | def get(organization) do | |
| 13 | 0 | result = |
| 14 | 0 | fn -> get_json("/api/customers/#{organization}") end |
| 15 | |> Hexpm.HTTP.retry("billing") | |
| 16 | ||
| 17 | 0 | case result do |
| 18 | 0 | {:ok, 200, _headers, body} -> body |
| 19 | 0 | {:ok, 404, _headers, _body} -> nil |
| 20 | end | |
| 21 | end | |
| 22 | ||
| 23 | def cancel(organization) do | |
| 24 | 0 | {:ok, 200, _headers, body} = post("/api/customers/#{organization}/cancel", %{}) |
| 25 | 0 | body |
| 26 | end | |
| 27 | ||
| 28 | def create(params) do | |
| 29 | 0 | case post("/api/customers", params) do |
| 30 | 0 | {:ok, 200, _headers, body} -> {:ok, body} |
| 31 | 0 | {:ok, 422, _headers, body} -> {:error, body} |
| 32 | end | |
| 33 | end | |
| 34 | ||
| 35 | def update(organization, params) do | |
| 36 | 0 | case patch("/api/customers/#{organization}", params) do |
| 37 | 0 | {:ok, 200, _headers, body} -> {:ok, body} |
| 38 | 0 | {:ok, 404, _headers, _body} -> {:ok, nil} |
| 39 | 0 | {:ok, 422, _headers, body} -> {:error, body} |
| 40 | end | |
| 41 | end | |
| 42 | ||
| 43 | def change_plan(organization, params) do | |
| 44 | 0 | {:ok, 204, _headers, _body} = post("/api/customers/#{organization}/plan", params) |
| 45 | :ok | |
| 46 | end | |
| 47 | ||
| 48 | def invoice(id) do | |
| 49 | 0 | {:ok, 200, _headers, body} = |
| 50 | 0 | fn -> get_html("/api/invoices/#{id}/html") end |
| 51 | |> Hexpm.HTTP.retry("billing") | |
| 52 | ||
| 53 | 0 | body |
| 54 | end | |
| 55 | ||
| 56 | def pay_invoice(id) do | |
| 57 | 0 | result = |
| 58 | 0 | fn -> post("/api/invoices/#{id}/pay", %{}) end |
| 59 | |> Hexpm.HTTP.retry("billing") | |
| 60 | ||
| 61 | 0 | case result do |
| 62 | 0 | {:ok, 204, _headers, _body} -> :ok |
| 63 | 0 | {:ok, 422, _headers, body} -> {:error, body} |
| 64 | end | |
| 65 | end | |
| 66 | ||
| 67 | def report() do | |
| 68 | 0 | {:ok, 200, _headers, body} = |
| 69 | 0 | fn -> get_json("/api/reports/customers") end |
| 70 | |> Hexpm.HTTP.retry("billing") | |
| 71 | ||
| 72 | 0 | body |
| 73 | end | |
| 74 | ||
| 75 | defp auth() do | |
| 76 | 0 | Application.get_env(:hexpm, :billing_key) |
| 77 | end | |
| 78 | ||
| 79 | defp post(url, body) do | |
| 80 | 0 | url = Application.get_env(:hexpm, :billing_url) <> url |
| 81 | 0 | body = Jason.encode!(body) |
| 82 | ||
| 83 | 0 | headers = [ |
| 84 | {"authorization", auth()}, | |
| 85 | {"accept", "application/json"}, | |
| 86 | {"content-type", "application/json"} | |
| 87 | ] | |
| 88 | ||
| 89 | :hackney.post(url, headers, body, recv_timeout: @timeout) | |
| 90 | 0 | |> read_request() |
| 91 | end | |
| 92 | ||
| 93 | defp patch(url, body) do | |
| 94 | 0 | url = Application.get_env(:hexpm, :billing_url) <> url |
| 95 | 0 | body = Jason.encode!(body) |
| 96 | ||
| 97 | 0 | headers = [ |
| 98 | {"authorization", auth()}, | |
| 99 | {"accept", "application/json"}, | |
| 100 | {"content-type", "application/json"} | |
| 101 | ] | |
| 102 | ||
| 103 | :hackney.patch(url, headers, body, recv_timeout: @timeout) | |
| 104 | 0 | |> read_request() |
| 105 | end | |
| 106 | ||
| 107 | defp get_json(url) do | |
| 108 | 0 | url = Application.get_env(:hexpm, :billing_url) <> url |
| 109 | ||
| 110 | 0 | headers = [ |
| 111 | {"authorization", auth()}, | |
| 112 | {"accept", "application/json"} | |
| 113 | ] | |
| 114 | ||
| 115 | :hackney.get(url, headers, "", recv_timeout: @timeout) | |
| 116 | 0 | |> read_request() |
| 117 | end | |
| 118 | ||
| 119 | defp get_html(url) do | |
| 120 | 0 | url = Application.get_env(:hexpm, :billing_url) <> url |
| 121 | ||
| 122 | 0 | headers = [ |
| 123 | {"authorization", auth()}, | |
| 124 | {"accept", "text/html"} | |
| 125 | ] | |
| 126 | ||
| 127 | :hackney.get(url, headers, "", recv_timeout: @timeout) | |
| 128 | 0 | |> read_request() |
| 129 | end | |
| 130 | ||
| 131 | defp read_request(result) do | |
| 132 | 0 | with {:ok, status, headers, ref} <- result, |
| 133 | 0 | headers = normalize_headers(headers), |
| 134 | 0 | {:ok, body} <- :hackney.body(ref), |
| 135 | 0 | {:ok, body} <- decode_body(body, headers) do |
| 136 | 0 | {:ok, status, headers, body} |
| 137 | end | |
| 138 | end | |
| 139 | ||
| 140 | defp decode_body(body, headers) do | |
| 141 | 0 | case List.keyfind(headers, "content-type", 0) do |
| 142 | 0 | nil -> |
| 143 | {:ok, nil} | |
| 144 | ||
| 145 | {_, content_type} -> | |
| 146 | 0 | if String.contains?(content_type, "application/json") do |
| 147 | 0 | Jason.decode(body) |
| 148 | else | |
| 149 | {:ok, body} | |
| 150 | end | |
| 151 | end | |
| 152 | end | |
| 153 | ||
| 154 | defp normalize_headers(headers) do | |
| 155 | 0 | Enum.map(headers, fn {key, value} -> |
| 156 | {String.downcase(key), value} | |
| 157 | end) | |
| 158 | end | |
| 159 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Billing.Local do | |
| 1 | @behaviour Hexpm.Billing | |
| 2 | ||
| 3 | 0 | def checkout(_organization, _data) do |
| 4 | {:ok, %{}} | |
| 5 | end | |
| 6 | ||
| 7 | def get(_organization) do | |
| 8 | 0 | %{ |
| 9 | "checkout_html" => "", | |
| 10 | "monthly_cost" => 800, | |
| 11 | "invoices" => [] | |
| 12 | } | |
| 13 | end | |
| 14 | ||
| 15 | def cancel(_organization) do | |
| 16 | 0 | %{} |
| 17 | end | |
| 18 | ||
| 19 | 0 | def create(_params) do |
| 20 | {:ok, %{}} | |
| 21 | end | |
| 22 | ||
| 23 | 0 | def update(_organization, _params) do |
| 24 | {:ok, %{}} | |
| 25 | end | |
| 26 | ||
| 27 | 0 | def change_plan(_organization, _params) do |
| 28 | :ok | |
| 29 | end | |
| 30 | ||
| 31 | def invoice(_id) do | |
| 32 | 0 | %{} |
| 33 | end | |
| 34 | ||
| 35 | 0 | def pay_invoice(_id) do |
| 36 | :ok | |
| 37 | end | |
| 38 | ||
| 39 | 0 | def report() do |
| 40 | [] | |
| 41 | end | |
| 42 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Billing.Report do | |
| 1 | use GenServer | |
| 2 | import Ecto.Query, only: [from: 2] | |
| 3 | alias Hexpm.Repo | |
| 4 | alias Hexpm.Accounts.Organization | |
| 5 | ||
| 6 | @report_timeout 20_000 | |
| 7 | ||
| 8 | def start_link(opts \\ []) do | |
| 9 | 3 | GenServer.start_link(__MODULE__, opts, opts) |
| 10 | end | |
| 11 | ||
| 12 | def init(opts) do | |
| 13 | 3 | Process.send_after(self(), :update, opts[:interval]) |
| 14 | {:ok, opts} | |
| 15 | end | |
| 16 | ||
| 17 | def handle_info(:update, opts) do | |
| 18 | 2 | if Application.fetch_env!(:hexpm, :billing_report) and Repo.write_mode?() do |
| 19 | 2 | report = report() |
| 20 | 2 | organizations = organizations() |
| 21 | ||
| 22 | 2 | set_active(organizations, report) |
| 23 | 2 | set_inactive(organizations, report) |
| 24 | end | |
| 25 | ||
| 26 | 2 | Process.send_after(self(), :update, opts[:interval]) |
| 27 | {:noreply, opts} | |
| 28 | end | |
| 29 | ||
| 30 | defp report() do | |
| 31 | report_request() | |
| 32 | |> MapSet.new() | |
| 33 | 2 | |> MapSet.put("hexpm") |
| 34 | end | |
| 35 | ||
| 36 | defp report_request() do | |
| 37 | 2 | Task.async(fn -> Hexpm.Billing.report() end) |
| 38 | 2 | |> Task.await(@report_timeout) |
| 39 | end | |
| 40 | ||
| 41 | defp organizations() do | |
| 42 | from(r in Organization, select: {r.name, r.billing_active}) | |
| 43 | 2 | |> Repo.all() |
| 44 | end | |
| 45 | ||
| 46 | defp set_active(organizations, report) do | |
| 47 | 2 | to_update = |
| 48 | Enum.flat_map(organizations, fn {name, active} -> | |
| 49 | 10 | if not active and name in report do |
| 50 | [name] | |
| 51 | else | |
| 52 | [] | |
| 53 | end | |
| 54 | end) | |
| 55 | ||
| 56 | 2 | if to_update != [] do |
| 57 | from(r in Organization, where: r.name in ^to_update) | |
| 58 | 1 | |> Repo.update_all(set: [billing_active: true]) |
| 59 | end | |
| 60 | end | |
| 61 | ||
| 62 | defp set_inactive(organizations, report) do | |
| 63 | 2 | to_update = |
| 64 | Enum.flat_map(organizations, fn {name, active} -> | |
| 65 | 10 | if active and name not in report do |
| 66 | [name] | |
| 67 | else | |
| 68 | [] | |
| 69 | end | |
| 70 | end) | |
| 71 | ||
| 72 | 2 | if to_update != [] do |
| 73 | from(r in Organization, where: r.name in ^to_update) | |
| 74 | 2 | |> Repo.update_all(set: [billing_active: false]) |
| 75 | end | |
| 76 | end | |
| 77 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.BlockAddress do | |
| 1 | @ets :blocked_addresses | |
| 2 | ||
| 3 | def start() do | |
| 4 | 1 | :ets.new(@ets, [:named_table, :set, :public, read_concurrency: true]) |
| 5 | 1 | :ets.insert(@ets, {:loaded, false}) |
| 6 | end | |
| 7 | ||
| 8 | def try_reload() do | |
| 9 | 1237 | case :ets.lookup(@ets, :loaded) do |
| 10 | [{:loaded, false}] -> | |
| 11 | 0 | reload() |
| 12 | ||
| 13 | 1237 | _ -> |
| 14 | :ok | |
| 15 | end | |
| 16 | end | |
| 17 | ||
| 18 | def reload() do | |
| 19 | 7 | disallowed = |
| 20 | Hexpm.BlockAddress.Entry | |
| 21 | |> Hexpm.Repo.all() | |
| 22 | 4 | |> Enum.map(&Hexpm.Utils.parse_ip_mask(&1.ip)) |
| 23 | 4 | |> Enum.reject(fn {ip, _mask} -> ip == nil end) |
| 24 | |> Enum.uniq() | |
| 25 | ||
| 26 | 7 | :ets.insert(@ets, {:allowed, Hexpm.CDN.public_ips()}) |
| 27 | 7 | :ets.insert(@ets, {:disallowed, disallowed}) |
| 28 | 7 | :ets.insert(@ets, {:loaded, true}) |
| 29 | end | |
| 30 | ||
| 31 | def blocked?(ip) do | |
| 32 | 618 | lookup_ip_mask(:disallowed, ip) |
| 33 | end | |
| 34 | ||
| 35 | def allowed?(ip) do | |
| 36 | 619 | lookup_ip_mask(:allowed, ip) |
| 37 | end | |
| 38 | ||
| 39 | defp lookup_ip_mask(key, ip) do | |
| 40 | 1237 | case :ets.lookup(@ets, key) do |
| 41 | [{^key, masks}] -> | |
| 42 | 1237 | ip = Hexpm.Utils.parse_ip(ip) |
| 43 | 1237 | Hexpm.Utils.in_ip_range?(masks, ip) |
| 44 | ||
| 45 | 0 | [] -> |
| 46 | false | |
| 47 | end | |
| 48 | end | |
| 49 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.BlockAddress.Entry do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | # TODO: rename to block_address_entries | |
| 4 | 12 | schema "blocked_addresses" do |
| 5 | field :ip, :string | |
| 6 | field :comment, :string | |
| 7 | end | |
| 8 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.CDN do | |
| 1 | @type service :: atom | |
| 2 | @type key :: String.t() | |
| 3 | @type ip :: <<_::32>> | |
| 4 | @type mask :: 0..32 | |
| 5 | ||
| 6 | @callback purge_key(service, key | [key]) :: :ok | |
| 7 | @callback public_ips() :: [{ip, mask}] | |
| 8 | ||
| 9 | 151 | defp impl(), do: Application.get_env(:hexpm, :cdn_impl) |
| 10 | ||
| 11 | 144 | def purge_key(service, key), do: impl().purge_key(service, key) |
| 12 | 7 | def public_ips(), do: impl().public_ips() |
| 13 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.CDN.Fastly do | |
| 1 | @behaviour Hexpm.CDN | |
| 2 | @fastly_url "https://api.fastly.com/" | |
| 3 | ||
| 4 | def purge_key(service, keys) do | |
| 5 | 0 | keys = keys |> List.wrap() |> Enum.uniq() |
| 6 | 0 | body = %{"surrogate_keys" => keys} |
| 7 | 0 | service_id = Application.get_env(:hexpm, service) |
| 8 | ||
| 9 | 0 | {:ok, 200, _, _} = post("service/#{service_id}/purge", body) |
| 10 | :ok | |
| 11 | end | |
| 12 | ||
| 13 | def public_ips() do | |
| 14 | 0 | {:ok, 200, _, body} = get("public-ip-list") |
| 15 | 0 | Enum.map(body["addresses"], &Hexpm.Utils.parse_ip_mask/1) |
| 16 | end | |
| 17 | ||
| 18 | defp auth() do | |
| 19 | 0 | Application.get_env(:hexpm, :fastly_key) |
| 20 | end | |
| 21 | ||
| 22 | defp post(url, body) do | |
| 23 | 0 | url = @fastly_url <> url |
| 24 | ||
| 25 | 0 | headers = [ |
| 26 | "fastly-key": auth(), | |
| 27 | accept: "application/json", | |
| 28 | "content-type": "application/json" | |
| 29 | ] | |
| 30 | ||
| 31 | 0 | body = Jason.encode!(body) |
| 32 | ||
| 33 | 0 | fn -> :hackney.post(url, headers, body, []) end |
| 34 | |> Hexpm.HTTP.retry("fastly") | |
| 35 | 0 | |> read_body() |
| 36 | end | |
| 37 | ||
| 38 | defp get(url) do | |
| 39 | 0 | url = @fastly_url <> url |
| 40 | 0 | headers = ["fastly-key": auth(), accept: "application/json"] |
| 41 | ||
| 42 | 0 | fn -> :hackney.get(url, headers, []) end |
| 43 | |> Hexpm.HTTP.retry("fastly") | |
| 44 | 0 | |> read_body() |
| 45 | end | |
| 46 | ||
| 47 | defp read_body({:ok, status, headers, client}) do | |
| 48 | 0 | {:ok, body} = :hackney.body(client) |
| 49 | ||
| 50 | 0 | body = |
| 51 | case Jason.decode(body) do | |
| 52 | 0 | {:ok, map} -> map |
| 53 | 0 | {:error, _} -> body |
| 54 | end | |
| 55 | ||
| 56 | 0 | {:ok, status, headers, body} |
| 57 | end | |
| 58 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.CDN.Local do | |
| 1 | @behaviour Hexpm.CDN | |
| 2 | ||
| 3 | 144 | def purge_key(_service, _key), do: :ok |
| 4 | 7 | def public_ips, do: [{<<127, 0, 0, 0>>, 24}] |
| 5 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Context do | |
| 1 | defmacro __using__(_opts) do | |
| 2 | quote do | |
| 3 | import Ecto | |
| 4 | import Ecto.Changeset | |
| 5 | import Ecto.Query, only: [from: 1, from: 2] | |
| 6 | ||
| 7 | import Hexpm.Accounts.AuditLog, | |
| 8 | only: [audit: 3, audit: 4, audit_many: 4, audit_with_user: 4] | |
| 9 | ||
| 10 | alias Ecto.Multi | |
| 11 | alias Hexpm.Repo | |
| 12 | ||
| 13 | use Hexpm.Shared | |
| 14 | end | |
| 15 | end | |
| 16 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Changeset do | |
| 1 | @moduledoc """ | |
| 2 | Ecto changeset helpers. | |
| 3 | """ | |
| 4 | ||
| 5 | import Ecto.Changeset | |
| 6 | ||
| 7 | @doc """ | |
| 8 | Checks if a version is valid semver. | |
| 9 | """ | |
| 10 | def validate_version(changeset, field) do | |
| 11 | 90 | validate_change(changeset, field, fn |
| 12 | 70 | _, %Version{build: nil} -> |
| 13 | [] | |
| 14 | ||
| 15 | 0 | _, %Version{} -> |
| 16 | [{field, "build number not allowed"}] | |
| 17 | end) | |
| 18 | end | |
| 19 | ||
| 20 | def validate_list_required(changeset, field, opts \\ []) do | |
| 21 | 174 | validate_change(changeset, field, fn |
| 22 | 2 | _, [] -> |
| 23 | [{field, Keyword.get(opts, :message, "can't be blank")}] | |
| 24 | ||
| 25 | 167 | _, list when is_list(list) -> |
| 26 | [] | |
| 27 | end) | |
| 28 | end | |
| 29 | ||
| 30 | def validate_requirement(changeset, field) do | |
| 31 | 110 | validate_change(changeset, field, fn key, req -> |
| 32 | 24 | cond do |
| 33 | 0 | is_nil(req) -> |
| 34 | [{key, "invalid requirement: #{inspect(req)}, use \">= 0.0.0\" instead"}] | |
| 35 | ||
| 36 | 24 | not valid_requirement?(req) -> |
| 37 | [{key, "invalid requirement: #{inspect(req)}"}] | |
| 38 | ||
| 39 | 21 | String.contains?(req, "!=") -> |
| 40 | [{key, "invalid requirement: #{inspect(req)}, != is not allowed in requirements"}] | |
| 41 | ||
| 42 | 21 | true -> |
| 43 | [] | |
| 44 | end | |
| 45 | end) | |
| 46 | end | |
| 47 | ||
| 48 | defp valid_requirement?(req) do | |
| 49 | 24 | is_binary(req) and match?({:ok, _}, Version.parse_requirement(req)) |
| 50 | end | |
| 51 | ||
| 52 | def validate_verified_email_exists(changeset, field, opts) do | |
| 53 | 25 | validate_change(changeset, field, fn _, email -> |
| 54 | 23 | case Hexpm.Repo.get_by(Hexpm.Accounts.Email, email: email, verified: true) do |
| 55 | 21 | nil -> |
| 56 | [] | |
| 57 | ||
| 58 | 2 | _ -> |
| 59 | [{field, opts[:message]}] | |
| 60 | end | |
| 61 | end) | |
| 62 | end | |
| 63 | ||
| 64 | def validate_repository(changeset, field, opts) do | |
| 65 | 23 | validate_change(changeset, field, fn key, dependency_repository -> |
| 66 | 16 | organization = Keyword.fetch!(opts, :repository) |
| 67 | ||
| 68 | 16 | if dependency_repository in ["hexpm", organization.name] do |
| 69 | [] | |
| 70 | else | |
| 71 | [{key, {repository_error(organization, dependency_repository), []}}] | |
| 72 | end | |
| 73 | end) | |
| 74 | end | |
| 75 | ||
| 76 | defp repository_error(%{id: 1}, dependency_repository) do | |
| 77 | 2 | "dependencies can only belong to public repository \"hexpm\", " <> |
| 78 | "got: #{inspect(dependency_repository)}" | |
| 79 | end | |
| 80 | ||
| 81 | defp repository_error(%{name: name}, dependency_repository) do | |
| 82 | 0 | "dependencies can only belong to public repository \"hexpm\" " <> |
| 83 | "or current repository #{inspect(name)}, got: #{inspect(dependency_repository)}" | |
| 84 | end | |
| 85 | ||
| 86 | def validate_password(changeset, field, hash, opts \\ []) do | |
| 87 | 6 | error_param = "#{field}_current" |
| 88 | 6 | error_field = String.to_atom(error_param) |
| 89 | ||
| 90 | 6 | errors = |
| 91 | 6 | case Map.fetch(changeset.params, error_param) do |
| 92 | {:ok, value} -> | |
| 93 | 3 | hash = default_hash(hash) |
| 94 | ||
| 95 | 3 | if Bcrypt.verify_pass(value, hash), |
| 96 | do: [], | |
| 97 | else: [{error_field, {"is invalid", []}}] | |
| 98 | ||
| 99 | 3 | :error -> |
| 100 | [{error_field, {"can't be blank", []}}] | |
| 101 | end | |
| 102 | ||
| 103 | %{ | |
| 104 | changeset | |
| 105 | 6 | | validations: [{:password, opts} | changeset.validations], |
| 106 | 6 | errors: errors ++ changeset.errors, |
| 107 | 6 | valid?: changeset.valid? and errors == [] |
| 108 | } | |
| 109 | end | |
| 110 | ||
| 111 | @default_password Bcrypt.hash_pwd_salt("password") | |
| 112 | ||
| 113 | 0 | defp default_hash(nil), do: @default_password |
| 114 | 0 | defp default_hash(""), do: @default_password |
| 115 | 3 | defp default_hash(password), do: password |
| 116 | ||
| 117 | def put_default_embed(changeset, key, value) do | |
| 118 | 231 | if get_change(changeset, key) do |
| 119 | 4 | changeset |
| 120 | else | |
| 121 | 227 | put_embed(changeset, key, value) |
| 122 | end | |
| 123 | end | |
| 124 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Version do | |
| 1 | @behaviour Ecto.Type | |
| 2 | ||
| 3 | 1104 | def type(), do: :string |
| 4 | ||
| 5 | 234 | def cast(%Version{} = version), do: {:ok, version} |
| 6 | ||
| 7 | def cast(string) when is_binary(string) do | |
| 8 | 594 | case Version.parse(string) do |
| 9 | {:ok, _} = ok -> | |
| 10 | 590 | ok |
| 11 | ||
| 12 | 4 | :error -> |
| 13 | {:error, message: "is invalid SemVer"} | |
| 14 | end | |
| 15 | end | |
| 16 | ||
| 17 | 0 | def cast(_), do: {:error, message: "is invalid SemVer"} |
| 18 | ||
| 19 | 407 | def load(string), do: Version.parse(string) |
| 20 | ||
| 21 | 697 | def dump(%Version{} = version), do: {:ok, to_string(version)} |
| 22 | 0 | def dump(version) when is_binary(version), do: {:ok, version} |
| 23 | ||
| 24 | 0 | def embed_as(_format), do: :self |
| 25 | ||
| 26 | 0 | def equal?(nil, nil), do: true |
| 27 | 70 | def equal?(nil, _right), do: false |
| 28 | 0 | def equal?(_left, nil), do: false |
| 29 | 12 | def equal?(left, right), do: Version.compare(left, right) == :eq |
| 30 | end | |
| 31 | ||
| 32 | defimpl Jason.Encoder, for: Version do | |
| 33 | 161 | def encode(version, _), do: ~s("#{version}") |
| 34 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defimpl Bamboo.Formatter, for: Hexpm.Accounts.User do | |
| 1 | 104 | def format_email_address(user, _opts) do |
| 2 | 104 | {user.username, Hexpm.Accounts.User.email(user, :primary)} |
| 3 | end | |
| 4 | end | |
| 5 | ||
| 6 | defimpl Bamboo.Formatter, for: Hexpm.Accounts.Email do | |
| 7 | 25 | def format_email_address(email, _opts) do |
| 8 | 25 | {email.user.username, email.email} |
| 9 | end | |
| 10 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Emails do | |
| 1 | use Bamboo.Phoenix, view: HexpmWeb.EmailView | |
| 2 | import Bamboo.Email | |
| 3 | alias Hexpm.Accounts.{Email, User} | |
| 4 | ||
| 5 | def owner_added(package, owners, owner) do | |
| 6 | email() | |
| 7 | |> email_to(owners) | |
| 8 | 11 | |> subject("Hex.pm - Owner added to package #{package.name}") |
| 9 | 11 | |> assign(:username, owner.username) |
| 10 | 11 | |> assign(:package, package.name) |
| 11 | 11 | |> render(:owner_add) |
| 12 | end | |
| 13 | ||
| 14 | def owner_removed(package, owners, owner) do | |
| 15 | email() | |
| 16 | |> email_to(owners) | |
| 17 | 4 | |> subject("Hex.pm - Owner removed from package #{package.name}") |
| 18 | 4 | |> assign(:username, owner.username) |
| 19 | 4 | |> assign(:package, package.name) |
| 20 | 4 | |> render(:owner_remove) |
| 21 | end | |
| 22 | ||
| 23 | def verification(user, email) do | |
| 24 | email() | |
| 25 | |> email_to(%{email | user: user}) | |
| 26 | |> subject("Hex.pm - Email verification") | |
| 27 | 25 | |> assign(:username, user.username) |
| 28 | 25 | |> assign(:email, email.email) |
| 29 | 25 | |> assign(:key, email.verification_key) |
| 30 | 25 | |> render(:verification) |
| 31 | end | |
| 32 | ||
| 33 | def password_reset_request(user, reset) do | |
| 34 | email() | |
| 35 | |> email_to(user) | |
| 36 | |> subject("Hex.pm - Password reset request") | |
| 37 | 11 | |> assign(:username, user.username) |
| 38 | 11 | |> assign(:key, reset.key) |
| 39 | 11 | |> render(:password_reset_request) |
| 40 | end | |
| 41 | ||
| 42 | def password_changed(user) do | |
| 43 | email() | |
| 44 | |> email_to(user) | |
| 45 | |> subject("Hex.pm - Your password has changed") | |
| 46 | 2 | |> assign(:username, user.username) |
| 47 | 2 | |> render(:password_changed) |
| 48 | end | |
| 49 | ||
| 50 | def typosquat_candidates(candidates, threshold) do | |
| 51 | email() | |
| 52 | |> email_to(Application.get_env(:hexpm, :support_email)) | |
| 53 | |> subject("[TYPOSQUAT CANDIDATES]") | |
| 54 | |> assign(:candidates, candidates) | |
| 55 | |> assign(:threshold, threshold) | |
| 56 | 0 | |> render(:typosquat_candidates) |
| 57 | end | |
| 58 | ||
| 59 | def organization_invite(organization, user) do | |
| 60 | email() | |
| 61 | |> email_to(user) | |
| 62 | 3 | |> subject("Hex.pm - You have been added to the #{organization.name} organization") |
| 63 | 3 | |> assign(:organization, organization.name) |
| 64 | 3 | |> assign(:username, user.username) |
| 65 | 3 | |> render(:organization_invite) |
| 66 | end | |
| 67 | ||
| 68 | def package_published(owners, publisher, name, version) do | |
| 69 | email() | |
| 70 | |> email_to(owners) | |
| 71 | 28 | |> subject("Hex.pm - Package #{name} v#{version} published") |
| 72 | |> assign(:publisher, publisher) | |
| 73 | |> assign(:version, version) | |
| 74 | |> assign(:package, name) | |
| 75 | 28 | |> render(:package_published) |
| 76 | end | |
| 77 | ||
| 78 | def report_submitted(receiver, author_name, package_name, report_id, inserted_at) do | |
| 79 | email() | |
| 80 | |> email_to(receiver) | |
| 81 | 7 | |> subject("Hex.pm - Package report on #{package_name} published ") |
| 82 | |> assign(:package_name, package_name) | |
| 83 | |> assign(:author_name, author_name) | |
| 84 | |> assign(:report_id, report_id) | |
| 85 | |> assign(:inserted_at, inserted_at) | |
| 86 | 7 | |> render(:report_submitted) |
| 87 | end | |
| 88 | ||
| 89 | def report_commented(receiver, author_name, report_id, inserted_at) do | |
| 90 | email() | |
| 91 | |> email_to(receiver) | |
| 92 | 4 | |> subject("Hex.pm - New comment on package report ##{report_id}") |
| 93 | |> assign(:author_name, author_name) | |
| 94 | |> assign(:report_id, report_id) | |
| 95 | |> assign(:inserted_at, inserted_at) | |
| 96 | 4 | |> render(:report_commented) |
| 97 | end | |
| 98 | ||
| 99 | def report_state_changed(receiver, report_id, new_state, updated_at) do | |
| 100 | email() | |
| 101 | |> email_to(receiver) | |
| 102 | 29 | |> subject("Hex.pm - Package report ##{report_id} has been reviewed by a moderator") |
| 103 | |> assign(:report_id, report_id) | |
| 104 | |> assign(:new_state, new_state) | |
| 105 | |> assign(:updated_at, updated_at) | |
| 106 | 29 | |> render(:report_state_changed) |
| 107 | end | |
| 108 | ||
| 109 | defp email_to(email, to) do | |
| 110 | 124 | to = |
| 111 | to | |
| 112 | |> List.wrap() | |
| 113 | |> Enum.flat_map(&expand_organization/1) | |
| 114 | |> Enum.sort() | |
| 115 | ||
| 116 | 124 | to(email, to) |
| 117 | end | |
| 118 | ||
| 119 | 0 | defp expand_organization(email) when is_binary(email), do: [email] |
| 120 | 25 | defp expand_organization(%Email{} = email), do: [email] |
| 121 | 44 | defp expand_organization(%User{organization: nil} = user), do: [user] |
| 122 | 60 | defp expand_organization(%User{organization: %Ecto.Association.NotLoaded{}} = user), do: [user] |
| 123 | ||
| 124 | defp expand_organization(%User{organization: organization}) do | |
| 125 | 5 | organization.organization_users |
| 126 | 4 | |> Enum.filter(&(&1.role == "admin")) |
| 127 | 5 | |> Enum.map(&User.email(&1.user, :primary)) |
| 128 | end | |
| 129 | ||
| 130 | defp email() do | |
| 131 | new_email() | |
| 132 | |> from(source()) | |
| 133 | 124 | |> put_layout({HexpmWeb.EmailView, :layout}) |
| 134 | end | |
| 135 | ||
| 136 | defp source() do | |
| 137 | 124 | host = Application.get_env(:hexpm, :email_host) || "hex.pm" |
| 138 | 124 | {"Hex.pm", "noreply@#{host}"} |
| 139 | end | |
| 140 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Emails.Mailer do | |
| 1 | use Bamboo.Mailer, otp_app: :hexpm | |
| 2 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Factory do | |
| 1 | use ExMachina.Ecto, repo: Hexpm.Repo | |
| 2 | alias Hexpm.Fake | |
| 3 | ||
| 4 | @password Bcrypt.hash_pwd_salt("password") | |
| 5 | @checksum "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855" | |
| 6 | ||
| 7 | def user_factory() do | |
| 8 | 1619 | %Hexpm.Accounts.User{ |
| 9 | username: Fake.sequence(:username), | |
| 10 | password: @password, | |
| 11 | full_name: Fake.random(:full_name), | |
| 12 | emails: [build(:email)] | |
| 13 | } | |
| 14 | end | |
| 15 | ||
| 16 | def email_factory() do | |
| 17 | 1673 | %Hexpm.Accounts.Email{ |
| 18 | email: Fake.sequence(:email), | |
| 19 | verified: true, | |
| 20 | primary: true, | |
| 21 | public: true, | |
| 22 | gravatar: true | |
| 23 | } | |
| 24 | end | |
| 25 | ||
| 26 | def key_factory() do | |
| 27 | 164 | {user_secret, first, second} = Hexpm.Accounts.Key.gen_key() |
| 28 | ||
| 29 | 164 | %Hexpm.Accounts.Key{ |
| 30 | 164 | name: "#{Fake.random(:username)}-#{:erlang.unique_integer()}", |
| 31 | secret_first: first, | |
| 32 | secret_second: second, | |
| 33 | user_secret: user_secret, | |
| 34 | permissions: [build(:key_permission, domain: "api")], | |
| 35 | user: nil, | |
| 36 | organization: nil | |
| 37 | } | |
| 38 | end | |
| 39 | ||
| 40 | def key_permission_factory() do | |
| 41 | 350 | %Hexpm.Accounts.KeyPermission{} |
| 42 | end | |
| 43 | ||
| 44 | def user_handles_factory() do | |
| 45 | 5 | %Hexpm.Accounts.UserHandles{} |
| 46 | end | |
| 47 | ||
| 48 | def organization_factory() do | |
| 49 | 491 | name = Fake.sequence(:package) |
| 50 | ||
| 51 | 491 | %Hexpm.Accounts.Organization{ |
| 52 | name: name, | |
| 53 | user: build(:user, username: name), | |
| 54 | billing_active: true, | |
| 55 | trial_end: ~U[2020-01-01T00:00:00Z] | |
| 56 | } | |
| 57 | end | |
| 58 | ||
| 59 | def audit_log_factory() do | |
| 60 | 201 | %Hexpm.Accounts.AuditLog{ |
| 61 | action: "", | |
| 62 | params: %{} | |
| 63 | } | |
| 64 | end | |
| 65 | ||
| 66 | def repository_factory() do | |
| 67 | 342 | name = Fake.sequence(:package) |
| 68 | ||
| 69 | 342 | %Hexpm.Repository.Repository{ |
| 70 | name: name, | |
| 71 | organization: build(:organization, name: name, user: build(:user, username: name)) | |
| 72 | } | |
| 73 | end | |
| 74 | ||
| 75 | def package_factory() do | |
| 76 | 624 | %Hexpm.Repository.Package{ |
| 77 | name: Fake.sequence(:package), | |
| 78 | meta: build(:package_metadata), | |
| 79 | repository_id: 1 | |
| 80 | } | |
| 81 | end | |
| 82 | ||
| 83 | def package_metadata_factory() do | |
| 84 | 624 | %Hexpm.Repository.PackageMetadata{ |
| 85 | description: Fake.random(:sentence), | |
| 86 | licenses: ["MIT"] | |
| 87 | } | |
| 88 | end | |
| 89 | ||
| 90 | def package_owner_factory() do | |
| 91 | 260 | %Hexpm.Repository.PackageOwner{} |
| 92 | end | |
| 93 | ||
| 94 | def package_report_factory() do | |
| 95 | 115 | %Hexpm.Repository.PackageReport{} |
| 96 | end | |
| 97 | ||
| 98 | def package_report_release_factory() do | |
| 99 | 46 | %Hexpm.Repository.PackageReportRelease{} |
| 100 | end | |
| 101 | ||
| 102 | def organization_user_factory() do | |
| 103 | 249 | %Hexpm.Accounts.OrganizationUser{ |
| 104 | role: "read" | |
| 105 | } | |
| 106 | end | |
| 107 | ||
| 108 | def release_factory() do | |
| 109 | 541 | %Hexpm.Repository.Release{ |
| 110 | version: "1.0.0", | |
| 111 | inner_checksum: Base.decode16!(@checksum), | |
| 112 | outer_checksum: Base.decode16!(@checksum), | |
| 113 | meta: build(:release_metadata) | |
| 114 | } | |
| 115 | end | |
| 116 | ||
| 117 | def release_metadata_factory() do | |
| 118 | 823 | %Hexpm.Repository.ReleaseMetadata{ |
| 119 | app: Fake.random(:package), | |
| 120 | build_tools: ["mix"] | |
| 121 | } | |
| 122 | end | |
| 123 | ||
| 124 | def requirement_factory() do | |
| 125 | 32 | %Hexpm.Repository.Requirement{ |
| 126 | app: Fake.random(:package), | |
| 127 | optional: false | |
| 128 | } | |
| 129 | end | |
| 130 | ||
| 131 | def download_factory() do | |
| 132 | 32 | %Hexpm.Repository.Download{ |
| 133 | day: ~D[2017-01-01] | |
| 134 | } | |
| 135 | end | |
| 136 | ||
| 137 | def install_factory() do | |
| 138 | 16 | %Hexpm.Repository.Install{} |
| 139 | end | |
| 140 | ||
| 141 | def block_address_factory() do | |
| 142 | 4 | %Hexpm.BlockAddress.Entry{ |
| 143 | comment: "blocked" | |
| 144 | } | |
| 145 | end | |
| 146 | ||
| 147 | def short_url_factory() do | |
| 148 | 1 | %Hexpm.ShortURLs.ShortURL{ |
| 149 | url: "", | |
| 150 | short_code: "" | |
| 151 | } | |
| 152 | end | |
| 153 | ||
| 154 | def user_with_tfa_factory() do | |
| 155 | 19 | %Hexpm.Accounts.User{ |
| 156 | username: Fake.sequence(:username), | |
| 157 | password: @password, | |
| 158 | full_name: Fake.random(:full_name), | |
| 159 | emails: [build(:email)], | |
| 160 | tfa: build(:tfa) | |
| 161 | } | |
| 162 | end | |
| 163 | ||
| 164 | def tfa_factory() do | |
| 165 | 20 | %Hexpm.Accounts.TFA{ |
| 166 | secret: "OZIH4PZP53MCYZ6Z", | |
| 167 | app_enabled: true, | |
| 168 | tfa_enabled: true, | |
| 169 | recovery_codes: [ | |
| 170 | %{ | |
| 171 | id: Ecto.UUID.generate(), | |
| 172 | code: "1234-1234-1234-1234", | |
| 173 | used_at: nil | |
| 174 | }, | |
| 175 | %{ | |
| 176 | id: Ecto.UUID.generate(), | |
| 177 | code: "4321-4321-4321-4321", | |
| 178 | used_at: ~U[2020-01-01 00:00:00Z] | |
| 179 | } | |
| 180 | ] | |
| 181 | } | |
| 182 | end | |
| 183 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Fake do | |
| 1 | @files [ | |
| 2 | :packages, | |
| 3 | :first_names, | |
| 4 | :last_names, | |
| 5 | :usernames, | |
| 6 | :words | |
| 7 | ] | |
| 8 | ||
| 9 | @generators [ | |
| 10 | {:package, [:packages]}, | |
| 11 | {:first_name, [:first_names]}, | |
| 12 | {:last_name, [:last_names]}, | |
| 13 | {:username, [:usernames]}, | |
| 14 | {:word, [:words]}, | |
| 15 | {:email, [:usernames]}, | |
| 16 | {:full_name, [:first_names, :last_names]}, | |
| 17 | {:sentence, [:words]} | |
| 18 | ] | |
| 19 | ||
| 20 | def start() do | |
| 21 | 1 | :ets.new(__MODULE__, [:named_table, :public, read_concurrency: true]) |
| 22 | ||
| 23 | 1 | Enum.each(@files, &load_file/1) |
| 24 | ||
| 25 | 1 | Enum.each(@generators, fn {key, deps} -> |
| 26 | 8 | :ets.insert(__MODULE__, {key, 0}) |
| 27 | 8 | size = Enum.map(deps, &:ets.lookup_element(__MODULE__, {&1, :size}, 2)) |> Enum.min() |
| 28 | 8 | :ets.insert(__MODULE__, {{key, :size}, size}) |
| 29 | end) | |
| 30 | end | |
| 31 | ||
| 32 | 4823 | def sequence(key, opts \\ []) |
| 33 | 3281 | def random(key, opts \\ []) |
| 34 | ||
| 35 | Enum.each(@generators, fn {key, _deps} -> | |
| 36 | def sequence(unquote(key), opts) do | |
| 37 | 1701 | [{_key, size}] = :ets.lookup(__MODULE__, {unquote(key), :size}) |
| 38 | 1701 | counter = :ets.update_counter(__MODULE__, unquote(key), {2, 1}) |
| 39 | 1701 | opts = Keyword.put(opts, :num_objects, size) |
| 40 | 1701 | generator(unquote(key), counter, opts) |
| 41 | end | |
| 42 | ||
| 43 | def random(unquote(key), opts) do | |
| 44 | 1638 | [{_key, size}] = :ets.lookup(__MODULE__, {unquote(key), :size}) |
| 45 | 1638 | counter = Enum.random(1..size) - 1 |
| 46 | 1638 | opts = Keyword.put(opts, :num_objects, size) |
| 47 | 1638 | generator(unquote(key), counter, opts) |
| 48 | end | |
| 49 | end) | |
| 50 | ||
| 51 | defp load_file(name) do | |
| 52 | 5 | seed = seed() |
| 53 | 5 | :rand.seed(:exrop, {seed, seed, seed}) |
| 54 | ||
| 55 | 5 | path = Path.join(Application.app_dir(:hexpm, "priv/fake"), "#{name}.txt") |
| 56 | ||
| 57 | 5 | objects = |
| 58 | File.read!(path) | |
| 59 | |> String.split("\n", trim: true) | |
| 60 | |> Enum.shuffle() | |
| 61 | |> Stream.with_index() | |
| 62 | 12406 | |> Enum.map(fn {line, ix} -> {{name, ix}, line} end) |
| 63 | ||
| 64 | 5 | :ets.insert(__MODULE__, objects) |
| 65 | 5 | :ets.insert(__MODULE__, {{name, :size}, length(objects)}) |
| 66 | end | |
| 67 | ||
| 68 | defp seed() do | |
| 69 | 5 | if Code.ensure_loaded?(ExUnit) do |
| 70 | 5 | ExUnit.configuration()[:seed] |
| 71 | else | |
| 72 | 0 | |
| 73 | end | |
| 74 | end | |
| 75 | ||
| 76 | defp get!(key, counter, original_key \\ nil) do | |
| 77 | 15358 | case :ets.lookup(__MODULE__, {key, counter}) do |
| 78 | [{_key, value}] -> | |
| 79 | 15358 | value |
| 80 | ||
| 81 | [] -> | |
| 82 | 0 | raise "Ran out of fake data for #{original_key || key}" |
| 83 | end | |
| 84 | end | |
| 85 | ||
| 86 | 2335 | defp generator(:package, counter, _opts), do: get!(:packages, counter) |
| 87 | 0 | defp generator(:first_name, counter, _opts), do: get!(:first_names, counter) |
| 88 | 0 | defp generator(:last_name, counter, _opts), do: get!(:last_names, counter) |
| 89 | 1806 | defp generator(:username, counter, _opts), do: get!(:usernames, counter) |
| 90 | 0 | defp generator(:word, counter, _opts), do: get!(:words, counter) |
| 91 | ||
| 92 | defp generator(:email, counter, _opts) do | |
| 93 | 1701 | username = get!(:usernames, counter) |
| 94 | 1701 | "#{username}@example.com" |
| 95 | end | |
| 96 | ||
| 97 | defp generator(:full_name, counter, _opts) do | |
| 98 | 1638 | first_name = get!(:first_names, counter) |
| 99 | 1638 | last_name = get!(:last_names, counter) |
| 100 | 1638 | "#{first_name} #{last_name}" |
| 101 | end | |
| 102 | ||
| 103 | defp generator(:sentence, counter, opts) do | |
| 104 | 624 | num = Keyword.get(opts, :size, 10) |
| 105 | 624 | num_objects = Keyword.fetch!(opts, :num_objects) |
| 106 | 624 | Enum.map_join(1..num, " ", &get!(:words, rem(counter + &1, num_objects))) |
| 107 | end | |
| 108 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.HTTP do | |
| 1 | require Logger | |
| 2 | ||
| 3 | @max_retry_times 3 | |
| 4 | @base_sleep_time 100 | |
| 5 | ||
| 6 | def get(url, headers) do | |
| 7 | :hackney.get(url, headers) | |
| 8 | 0 | |> read_response() |
| 9 | end | |
| 10 | ||
| 11 | def put(url, headers, body) do | |
| 12 | :hackney.put(url, headers, body) | |
| 13 | 0 | |> read_response() |
| 14 | end | |
| 15 | ||
| 16 | def delete(url, headers) do | |
| 17 | :hackney.delete(url, headers) | |
| 18 | 0 | |> read_response() |
| 19 | end | |
| 20 | ||
| 21 | defp read_response(result) do | |
| 22 | 0 | with {:ok, status, headers, ref} <- result, |
| 23 | 0 | {:ok, body} <- :hackney.body(ref) do |
| 24 | 0 | {:ok, status, headers, body} |
| 25 | end | |
| 26 | end | |
| 27 | ||
| 28 | def retry(fun, name) do | |
| 29 | 0 | retry(fun, name, 0) |
| 30 | end | |
| 31 | ||
| 32 | defp retry(fun, name, times) do | |
| 33 | 0 | case fun.() do |
| 34 | {:error, reason} -> | |
| 35 | 0 | Logger.warn("#{name} API ERROR: #{inspect(reason)}") |
| 36 | ||
| 37 | 0 | if times + 1 < @max_retry_times do |
| 38 | 0 | sleep = trunc(:math.pow(3, times) * @base_sleep_time) |
| 39 | 0 | :timer.sleep(sleep) |
| 40 | 0 | retry(fun, name, times + 1) |
| 41 | else | |
| 42 | {:error, reason} | |
| 43 | end | |
| 44 | ||
| 45 | result -> | |
| 46 | 0 | result |
| 47 | end | |
| 48 | end | |
| 49 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Pwned.HaveIBeenPwned do | |
| 1 | @behaviour Hexpm.Pwned | |
| 2 | ||
| 3 | @base_url "https://api.pwnedpasswords.com/" | |
| 4 | @weakness_threshold 1 | |
| 5 | @timeout 500 | |
| 6 | ||
| 7 | @spec password_breached?(String.t()) :: boolean | |
| 8 | def password_breached?(string_password) do | |
| 9 | string_password | |
| 10 | |> hash_password() | |
| 11 | |> occurrences_of_hash() | |
| 12 | 0 | |> Kernel.>=(@weakness_threshold) |
| 13 | end | |
| 14 | ||
| 15 | defp hash_password(string_password) do | |
| 16 | :sha | |
| 17 | |> :crypto.hash(string_password) | |
| 18 | 0 | |> Base.encode16() |
| 19 | end | |
| 20 | ||
| 21 | defp range(searchable_range) do | |
| 22 | 0 | url = @base_url <> "range/#{searchable_range}" |
| 23 | 0 | headers = [{"User-Agent", "hexpm"}] |
| 24 | ||
| 25 | 0 | case :hackney.get(url, headers, "", |
| 26 | connect_timeout: @timeout, | |
| 27 | recv_timeout: @timeout, | |
| 28 | with_body: true | |
| 29 | ) do | |
| 30 | {:ok, 200, _headers, body} -> | |
| 31 | 0 | String.split(body, "\r\n") |
| 32 | ||
| 33 | 0 | {:error, _} -> |
| 34 | [] | |
| 35 | end | |
| 36 | end | |
| 37 | ||
| 38 | defp occurrences_of_hash(<<searchable_range::bytes-5, remainder::binary>>) do | |
| 39 | searchable_range | |
| 40 | |> range() | |
| 41 | 0 | |> Enum.map(&String.split(&1, ":")) |
| 42 | |> Enum.find_value("0", fn | |
| 43 | 0 | [^remainder, occurrences] -> occurrences |
| 44 | 0 | _ -> nil |
| 45 | end) | |
| 46 | 0 | |> String.to_integer() |
| 47 | end | |
| 48 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Pwned.Local do | |
| 1 | @behaviour Hexpm.Pwned | |
| 2 | ||
| 3 | @spec password_breached?(String.t()) :: boolean | |
| 4 | 0 | def password_breached?("password"), do: true |
| 5 | 0 | def password_breached?(_), do: false |
| 6 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Pwned do | |
| 1 | @moduledoc """ | |
| 2 | This module acts as an interface to the haveibeenpwned API | |
| 3 | https://haveibeenpwned.com/API/v2 | |
| 4 | """ | |
| 5 | ||
| 6 | @callback password_breached?(String.t()) :: boolean() | |
| 7 | ||
| 8 | 12 | defp impl(), do: Application.get_env(:hexpm, :pwned_impl) |
| 9 | ||
| 10 | 12 | def password_breached?(password), do: impl().password_breached?(password) |
| 11 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.ReleaseTasks do | |
| 1 | alias Hexpm.ReleaseTasks.{CheckNames, Stats} | |
| 2 | require Logger | |
| 3 | ||
| 4 | @repo_apps [ | |
| 5 | :crypto, | |
| 6 | :ssl, | |
| 7 | :postgrex, | |
| 8 | :ecto_sql | |
| 9 | ] | |
| 10 | ||
| 11 | @repos Application.compile_env!(:hexpm, :ecto_repos) | |
| 12 | ||
| 13 | def script(args) do | |
| 14 | 0 | {:ok, _} = Application.ensure_all_started(:logger) |
| 15 | 0 | Logger.info("[task] Running script") |
| 16 | 0 | start_app() |
| 17 | ||
| 18 | 0 | task(fn -> run_script(args) end) |
| 19 | ||
| 20 | 0 | Logger.info("[task] Finished script") |
| 21 | 0 | stop() |
| 22 | end | |
| 23 | ||
| 24 | def check_names() do | |
| 25 | 0 | {:ok, _} = Application.ensure_all_started(:logger) |
| 26 | 0 | Logger.info("[task] Running check_names") |
| 27 | 0 | start_app() |
| 28 | ||
| 29 | 0 | task(&CheckNames.run/0) |
| 30 | ||
| 31 | 0 | Logger.info("[task] Finished check_names") |
| 32 | 0 | stop() |
| 33 | end | |
| 34 | ||
| 35 | def migrate(args \\ []) do | |
| 36 | 0 | {:ok, _} = Application.ensure_all_started(:logger) |
| 37 | 0 | Logger.info("[task] Running migrate") |
| 38 | 0 | start_repo() |
| 39 | ||
| 40 | 0 | task(fn -> run_migrations(args) end) |
| 41 | ||
| 42 | 0 | Logger.info("[task] Finished migrate") |
| 43 | 0 | stop() |
| 44 | end | |
| 45 | ||
| 46 | def rollback(args \\ []) do | |
| 47 | 0 | {:ok, _} = Application.ensure_all_started(:logger) |
| 48 | 0 | Logger.info("[task] Running rollback") |
| 49 | 0 | start_repo() |
| 50 | ||
| 51 | 0 | task(fn -> run_rollback(args) end) |
| 52 | ||
| 53 | 0 | Logger.info("[task] Finished rollback") |
| 54 | 0 | stop() |
| 55 | end | |
| 56 | ||
| 57 | def seed(args \\ []) do | |
| 58 | 0 | {:ok, _} = Application.ensure_all_started(:logger) |
| 59 | 0 | Logger.info("[task] Running seed") |
| 60 | ||
| 61 | 0 | task(fn -> |
| 62 | 0 | start_repo() |
| 63 | 0 | run_migrations(args) |
| 64 | 0 | run_seeds() |
| 65 | end) | |
| 66 | ||
| 67 | 0 | Logger.info("[task] Finished seed") |
| 68 | 0 | stop() |
| 69 | end | |
| 70 | ||
| 71 | def stats() do | |
| 72 | 0 | {:ok, _} = Application.ensure_all_started(:logger) |
| 73 | 0 | Logger.info("[task] Running stats") |
| 74 | 0 | start_app() |
| 75 | ||
| 76 | 0 | task(&Stats.run/0) |
| 77 | ||
| 78 | 0 | Logger.info("[task] Finished stats") |
| 79 | 0 | stop() |
| 80 | end | |
| 81 | ||
| 82 | 0 | defp task(fun) do |
| 83 | 0 | Process.flag(:trap_exit, true) |
| 84 | ||
| 85 | 0 | %Task{ref: ref} = |
| 86 | Task.async(fn -> | |
| 87 | 0 | try do |
| 88 | 0 | fun.() |
| 89 | catch | |
| 90 | kind, error -> | |
| 91 | 0 | Rollbax.report(kind, error, __STACKTRACE__) |
| 92 | end | |
| 93 | end) | |
| 94 | ||
| 95 | 0 | receive do |
| 96 | 0 | {^ref, _result} -> |
| 97 | :ok | |
| 98 | ||
| 99 | {:EXIT, _pid, {error, stacktrace}} -> | |
| 100 | 0 | Rollbax.report(:error, error, stacktrace) |
| 101 | end | |
| 102 | after | |
| 103 | 0 | Process.flag(:trap_exit, false) |
| 104 | end | |
| 105 | ||
| 106 | defp start_app() do | |
| 107 | 0 | Logger.info("[task] Starting app...") |
| 108 | 0 | Application.put_env(:phoenix, :serve_endpoints, false, persistent: true) |
| 109 | 0 | Application.put_env(:hexpm, :topologies, [], persistent: true) |
| 110 | 0 | {:ok, _} = Application.ensure_all_started(:hexpm) |
| 111 | end | |
| 112 | ||
| 113 | defp start_repo() do | |
| 114 | 0 | Logger.info("[task] Starting dependencies...") |
| 115 | ||
| 116 | 0 | Enum.each(@repo_apps, fn app -> |
| 117 | 0 | {:ok, _} = Application.ensure_all_started(app) |
| 118 | end) | |
| 119 | ||
| 120 | 0 | Logger.info("[task] Starting repos...") |
| 121 | ||
| 122 | 0 | Enum.each(@repos, fn repo -> |
| 123 | 0 | {:ok, _} = repo.start_link(pool_size: 2) |
| 124 | end) | |
| 125 | end | |
| 126 | ||
| 127 | defp stop() do | |
| 128 | 0 | Logger.info("[task] Stopping...") |
| 129 | 0 | :init.stop() |
| 130 | end | |
| 131 | ||
| 132 | defp run_migrations(args) do | |
| 133 | 0 | Enum.each(@repos, fn repo -> |
| 134 | 0 | app = Keyword.get(repo.config(), :otp_app) |
| 135 | 0 | Logger.info("[task] Running migrations for #{app}") |
| 136 | ||
| 137 | 0 | case args do |
| 138 | 0 | ["--step", n] -> migrate(repo, :up, step: String.to_integer(n)) |
| 139 | 0 | ["-n", n] -> migrate(repo, :up, step: String.to_integer(n)) |
| 140 | 0 | ["--to", to] -> migrate(repo, :up, to: to) |
| 141 | 0 | ["--all"] -> migrate(repo, :up, all: true) |
| 142 | 0 | [] -> migrate(repo, :up, all: true) |
| 143 | end | |
| 144 | end) | |
| 145 | end | |
| 146 | ||
| 147 | defp run_rollback(args) do | |
| 148 | 0 | Enum.each(@repos, fn repo -> |
| 149 | 0 | app = Keyword.get(repo.config(), :otp_app) |
| 150 | 0 | Logger.info("[task] Running rollback for #{app}") |
| 151 | ||
| 152 | 0 | case args do |
| 153 | 0 | ["--step", n] -> migrate(repo, :down, step: String.to_integer(n)) |
| 154 | 0 | ["-n", n] -> migrate(repo, :down, step: String.to_integer(n)) |
| 155 | 0 | ["--to", to] -> migrate(repo, :down, to: to) |
| 156 | 0 | ["--all"] -> migrate(repo, :down, all: true) |
| 157 | 0 | [] -> migrate(repo, :down, step: 1) |
| 158 | end | |
| 159 | end) | |
| 160 | end | |
| 161 | ||
| 162 | defp migrate(repo, direction, opts) do | |
| 163 | 0 | migrations_path = priv_path_for(repo, "migrations") |
| 164 | 0 | Ecto.Migrator.run(repo, migrations_path, direction, opts) |
| 165 | end | |
| 166 | ||
| 167 | defp run_seeds() do | |
| 168 | 0 | Enum.each(@repos, &run_seeds_for/1) |
| 169 | end | |
| 170 | ||
| 171 | defp run_seeds_for(repo) do | |
| 172 | # Run the seed script if it exists | |
| 173 | 0 | seed_script = priv_path_for(repo, "seeds.exs") |
| 174 | ||
| 175 | 0 | if File.exists?(seed_script) do |
| 176 | 0 | Logger.info("[task] Running seed script...") |
| 177 | 0 | Code.eval_file(seed_script) |
| 178 | end | |
| 179 | end | |
| 180 | ||
| 181 | defp priv_path_for(repo, filename) do | |
| 182 | 0 | app = Keyword.get(repo.config(), :otp_app) |
| 183 | 0 | priv_dir = Application.app_dir(app, "priv") |
| 184 | ||
| 185 | 0 | Path.join([priv_dir, "repo", filename]) |
| 186 | end | |
| 187 | ||
| 188 | # TODO: Move all scripts to release tasks | |
| 189 | defp run_script(args) do | |
| 190 | 0 | [script | args] = args |
| 191 | ||
| 192 | 0 | priv_dir = Application.app_dir(:hexpm, "priv") |
| 193 | 0 | script_dir = Path.join(priv_dir, "scripts") |
| 194 | 0 | original_argv = System.argv() |
| 195 | ||
| 196 | 0 | Logger.info("[task] Running #{script} #{inspect(args)}") |
| 197 | ||
| 198 | 0 | try do |
| 199 | 0 | System.argv(args) |
| 200 | 0 | Code.eval_file(script, script_dir) |
| 201 | after | |
| 202 | 0 | System.argv(original_argv) |
| 203 | end | |
| 204 | ||
| 205 | 0 | Logger.info("[task] Finished #{script} #{inspect(args)}") |
| 206 | end | |
| 207 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.ReleaseTasks.CheckNames do | |
| 1 | require Logger | |
| 2 | ||
| 3 | def run() do | |
| 4 | # Trigger error_handler and rollbar reporting on 'hexpm eval ...' | |
| 5 | Task.async(&do_run/0) | |
| 6 | 0 | |> Task.await(:infinity) |
| 7 | end | |
| 8 | ||
| 9 | 0 | def do_run() do |
| 10 | 0 | threshold = Application.get_env(:hexpm, :levenshtein_threshold) |
| 11 | ||
| 12 | threshold | |
| 13 | |> to_integer() | |
| 14 | |> find_candidates() | |
| 15 | |> log_result() | |
| 16 | 0 | |> send_email(threshold) |
| 17 | catch | |
| 18 | exception -> | |
| 19 | 0 | Logger.error("[check_names] failed") |
| 20 | 0 | reraise exception, __STACKTRACE__ |
| 21 | end | |
| 22 | ||
| 23 | defp log_result(candidates) do | |
| 24 | 0 | Logger.info("[check_names] job found #{length(candidates)} candidates") |
| 25 | 0 | candidates |
| 26 | end | |
| 27 | ||
| 28 | 0 | defp send_email([], _threshold), do: :ok |
| 29 | ||
| 30 | defp send_email(candidates, threshold) do | |
| 31 | candidates | |
| 32 | |> Hexpm.Emails.typosquat_candidates(threshold) | |
| 33 | 0 | |> Hexpm.Emails.Mailer.deliver_later!() |
| 34 | end | |
| 35 | ||
| 36 | def find_candidates(threshold) do | |
| 37 | 1 | query = """ |
| 38 | SELECT pnew.name new_name, pall.name curr_name, levenshtein(pall.name, pnew.name) as dist | |
| 39 | FROM packages as pall | |
| 40 | CROSS JOIN packages as pnew | |
| 41 | WHERE pall.name <> pnew.name | |
| 42 | AND pnew.inserted_at >= CURRENT_DATE AT TIME ZONE 'UTC' | |
| 43 | AND levenshtein(pall.name, pnew.name) <= $1 | |
| 44 | ORDER BY pall.name, dist | |
| 45 | """ | |
| 46 | ||
| 47 | Hexpm.Repo.query!(query, [threshold]) | |
| 48 | |> Map.fetch!(:rows) | |
| 49 | 1 | |> Enum.uniq_by(fn [a, b, _] -> if a > b, do: "#{a}-#{b}", else: "#{b}-#{a}" end) |
| 50 | end | |
| 51 | ||
| 52 | 0 | defp to_integer(int) when is_integer(int), do: int |
| 53 | 0 | defp to_integer(string) when is_binary(string), do: String.to_integer(string) |
| 54 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.ReleaseTasks.Stats do | |
| 1 | require Logger | |
| 2 | import Ecto.Query, only: [from: 2] | |
| 3 | alias Hexpm.{Repo, Store, Utils} | |
| 4 | ||
| 5 | alias Hexpm.Repository.{ | |
| 6 | Download, | |
| 7 | Package, | |
| 8 | PackageDownload, | |
| 9 | Release, | |
| 10 | ReleaseDownload, | |
| 11 | Repository | |
| 12 | } | |
| 13 | ||
| 14 | @fastly_regex ~r< | |
| 15 | [^\040]+\040 # syslog | |
| 16 | [^\040]+\040 # user | |
| 17 | [^\040]+\040 # source | |
| 18 | [^\040]+\040 # IP address | |
| 19 | (?:(?:"[^"]+")|(?:\[[^\]]+\]))\040 # time | |
| 20 | "GET\040/ | |
| 21 | (?:repos/([^/]+)/)? # repository | |
| 22 | tarballs/ | |
| 23 | ([^-]+) # package | |
| 24 | - | |
| 25 | ([\d\w\.\-]+) # version | |
| 26 | .tar | |
| 27 | (?:\?[^\040"]*)? | |
| 28 | (?:\040HTTP/\d\.\d)? | |
| 29 | "\040 | |
| 30 | ([0-9]{3})\040 # status | |
| 31 | >x | |
| 32 | ||
| 33 | @ets __MODULE__ | |
| 34 | ||
| 35 | def run(date \\ Utils.utc_yesterday(), dryrun? \\ false) do | |
| 36 | 1 | {time, size} = |
| 37 | :timer.tc(fn -> | |
| 38 | 1 | do_run(date, dryrun?) |
| 39 | end) | |
| 40 | ||
| 41 | 1 | Logger.info("[stats] completed #{size} downloads (#{div(time, 1000)}ms)") |
| 42 | end | |
| 43 | ||
| 44 | 1 | def do_run(date, dryrun?) do |
| 45 | 1 | :ets.new(@ets, [:named_table, :public]) |
| 46 | ||
| 47 | 1 | try do |
| 48 | 1 | process_buckets(date) |
| 49 | 1 | repositories = repositories() |
| 50 | 1 | packages = packages() |
| 51 | 1 | releases = releases() |
| 52 | ||
| 53 | # May not be a perfect count since it counts downloads without a release | |
| 54 | # in the database. Should be uncommon | |
| 55 | 1 | num = ets_stream() |> Enum.reduce(0, fn {_, count}, acc -> count + acc end) |
| 56 | ||
| 57 | 1 | unless dryrun? do |
| 58 | 1 | Repo.transaction( |
| 59 | fn -> | |
| 60 | 1 | Repo.delete_all(from(d in Download, where: d.day == ^date)) |
| 61 | ||
| 62 | ets_stream() | |
| 63 | |> Stream.flat_map(fn {{repository, package, version}, count} -> | |
| 64 | 5 | repository_id = repositories[repository] |
| 65 | 5 | package_id = packages[{repository_id, package}] |
| 66 | ||
| 67 | 5 | if release_id = releases[{package_id, version}] do |
| 68 | [%{package_id: package_id, release_id: release_id, downloads: count, day: date}] | |
| 69 | else | |
| 70 | [] | |
| 71 | end | |
| 72 | end) | |
| 73 | |> Stream.chunk_every(1000, 1000, []) | |
| 74 | 1 | |> Enum.each(&Repo.insert_all(Download, &1)) |
| 75 | ||
| 76 | 1 | Repo.refresh_view(PackageDownload) |
| 77 | 1 | Repo.refresh_view(ReleaseDownload) |
| 78 | end, | |
| 79 | timeout: 120_000 | |
| 80 | ) | |
| 81 | end | |
| 82 | ||
| 83 | 1 | num |
| 84 | after | |
| 85 | 1 | :ets.delete(@ets) |
| 86 | end | |
| 87 | catch | |
| 88 | exception -> | |
| 89 | 0 | Logger.error("[stats] failed") |
| 90 | 0 | reraise exception, __STACKTRACE__ |
| 91 | end | |
| 92 | ||
| 93 | def ets_stream() do | |
| 94 | 2 | start_fun = fn -> :ets.first(@ets) end |
| 95 | 2 | after_fun = fn _ -> :ok end |
| 96 | ||
| 97 | 2 | next_fun = fn |
| 98 | 2 | :"$end_of_table" -> {:halt, nil} |
| 99 | 10 | key -> {:ets.lookup(@ets, key), :ets.next(@ets, key)} |
| 100 | end | |
| 101 | ||
| 102 | 2 | Stream.resource(start_fun, next_fun, after_fun) |
| 103 | end | |
| 104 | ||
| 105 | defp process_buckets(date) do | |
| 106 | 1 | bucket = Application.get_env(:hexpm, :logs_bucket) |
| 107 | 1 | prefix = "fastly_hex/#{date}" |
| 108 | 1 | keys = Store.list(bucket, prefix) |> Enum.to_list() |
| 109 | 1 | process_keys(bucket, keys) |
| 110 | end | |
| 111 | ||
| 112 | defp process_keys(bucket, keys) do | |
| 113 | Task.async_stream( | |
| 114 | keys, | |
| 115 | fn key -> | |
| 116 | Store.get(bucket, key, []) | |
| 117 | |> maybe_unzip(key) | |
| 118 | 2 | |> process_file() |
| 119 | end, | |
| 120 | max_concurrency: 10, | |
| 121 | timeout: 600_000 | |
| 122 | ) | |
| 123 | 1 | |> Stream.run() |
| 124 | end | |
| 125 | ||
| 126 | defp process_file(file) do | |
| 127 | 2 | lines = String.split(file, "\n") |
| 128 | ||
| 129 | 2 | Enum.each(lines, fn line -> |
| 130 | 16 | case parse_line(line) do |
| 131 | {repository, package, version} -> | |
| 132 | 12 | key = {repository, package, version} |
| 133 | 12 | :ets.update_counter(@ets, key, 1, {key, 0}) |
| 134 | ||
| 135 | 4 | nil -> |
| 136 | :ok | |
| 137 | end | |
| 138 | end) | |
| 139 | end | |
| 140 | ||
| 141 | defp parse_line(line) do | |
| 142 | 16 | case Regex.run(@fastly_regex, line) do |
| 143 | [_, repository, package, version, status] when status in ~w(200 304) -> | |
| 144 | 12 | {copy(nillify(repository)) || "hexpm", copy(package), copy(version)} |
| 145 | ||
| 146 | 4 | _ -> |
| 147 | nil | |
| 148 | end | |
| 149 | end | |
| 150 | ||
| 151 | defp repositories() do | |
| 152 | from(r in Repository, select: {r.name, r.id}) | |
| 153 | |> Repo.all() | |
| 154 | 1 | |> Map.new() |
| 155 | end | |
| 156 | ||
| 157 | defp packages() do | |
| 158 | from(p in Package, select: {{p.repository_id, p.name}, p.id}) | |
| 159 | |> Repo.all() | |
| 160 | 1 | |> Map.new() |
| 161 | end | |
| 162 | ||
| 163 | defp releases() do | |
| 164 | from(r in Release, select: {{r.package_id, r.version}, r.id}) | |
| 165 | |> Repo.all() | |
| 166 | 1 | |> Map.new(fn {{pid, vsn}, rid} -> {{pid, to_string(vsn)}, rid} end) |
| 167 | end | |
| 168 | ||
| 169 | defp maybe_unzip(data, key) do | |
| 170 | 2 | if String.ends_with?(key, ".gz") do |
| 171 | 2 | :zlib.gunzip(data) |
| 172 | else | |
| 173 | 0 | data |
| 174 | end | |
| 175 | end | |
| 176 | ||
| 177 | 9 | defp nillify(""), do: nil |
| 178 | 3 | defp nillify(binary), do: binary |
| 179 | ||
| 180 | 9 | defp copy(nil), do: nil |
| 181 | 27 | defp copy(binary), do: :binary.copy(binary) |
| 182 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.RepoHelpers do | |
| 1 | defmacro defwrite({name, _meta, params}) do | |
| 2 | quote do | |
| 3 | def unquote(name)(unquote_splicing(params)) do | |
| 4 | write_mode!() | |
| 5 | Hexpm.RepoBase.unquote(name)(unquote_splicing(params_to_args(params))) | |
| 6 | end | |
| 7 | end | |
| 8 | end | |
| 9 | ||
| 10 | defp params_to_args(params) do | |
| 11 | 0 | Enum.map(params, fn |
| 12 | 0 | {:\\, _meta, [arg, _default]} -> arg |
| 13 | 0 | arg -> arg |
| 14 | end) | |
| 15 | end | |
| 16 | end | |
| 17 | ||
| 18 | defmodule Hexpm.Repo do | |
| 19 | import Hexpm.RepoHelpers | |
| 20 | alias Hexpm.RepoBase | |
| 21 | ||
| 22 | 37 | defdelegate aggregate(queryable, aggregate, field, opts \\ []), to: RepoBase |
| 23 | 619 | defdelegate all(queryable, opts \\ []), to: RepoBase |
| 24 | 34 | defdelegate get_by!(queryable, clauses, opts \\ []), to: RepoBase |
| 25 | 719 | defdelegate get_by(queryable, clauses, opts \\ []), to: RepoBase |
| 26 | 12 | defdelegate get!(queryable, id, opts \\ []), to: RepoBase |
| 27 | 177 | defdelegate get(queryable, id, opts \\ []), to: RepoBase |
| 28 | 223 | defdelegate one!(queryable, opts \\ []), to: RepoBase |
| 29 | 412 | defdelegate one(queryable, opts \\ []), to: RepoBase |
| 30 | 752 | defdelegate preload(structs_or_struct_or_nil, preloads, opts \\ []), to: RepoBase |
| 31 | ||
| 32 | 0 | defwrite(try_advisory_xact_lock?(key, opts \\ [])) |
| 33 | 48 | defwrite(try_advisory_lock?(key, opts \\ [])) |
| 34 | 48 | defwrite(advisory_unlock(key, opts \\ [])) |
| 35 | 5 | defwrite(delete_all(queryable, opts \\ [])) |
| 36 | 7 | defwrite(delete!(struct_or_changeset, opts \\ [])) |
| 37 | 0 | defwrite(delete(struct_or_changeset, opts \\ [])) |
| 38 | 4 | defwrite(insert_all(queryable, opts \\ [])) |
| 39 | 0 | defwrite(insert_or_update(changeset, opts \\ [])) |
| 40 | 3467 | defwrite(insert!(struct_or_changeset, opts \\ [])) |
| 41 | 20 | defwrite(insert(struct_or_changeset, opts \\ [])) |
| 42 | 1 | defwrite(query!(sql, params \\ [], opts \\ [])) |
| 43 | 54 | defwrite(query(sql, params \\ [], opts \\ [])) |
| 44 | 17 | defwrite(refresh_view(schema)) |
| 45 | 0 | defwrite(rollback(value)) |
| 46 | 406 | defwrite(transaction(fun_or_multi, opts \\ [])) |
| 47 | 6 | defwrite(update_all(queryable, opts \\ [])) |
| 48 | 275 | defwrite(update!(changeset, opts \\ [])) |
| 49 | 3 | defwrite(update(changeset, opts \\ [])) |
| 50 | ||
| 51 | def write_mode?() do | |
| 52 | 4789 | not Application.get_env(:hexpm, :read_only_mode, false) |
| 53 | end | |
| 54 | ||
| 55 | def write_mode!() do | |
| 56 | 4361 | unless write_mode?() do |
| 57 | 1 | raise Hexpm.WriteInReadOnlyMode |
| 58 | end | |
| 59 | end | |
| 60 | end | |
| 61 | ||
| 62 | defmodule Hexpm.RepoBase do | |
| 63 | use Ecto.Repo, | |
| 64 | otp_app: :hexpm, | |
| 65 | adapter: Ecto.Adapters.Postgres | |
| 66 | ||
| 67 | @advisory_locks %{ | |
| 68 | registry: 1 | |
| 69 | } | |
| 70 | ||
| 71 | def init(_reason, opts) do | |
| 72 | 1 | if url = System.get_env("HEXPM_DATABASE_URL") do |
| 73 | 0 | pool_size_env = System.get_env("HEXPM_DATABASE_POOL_SIZE") |
| 74 | 0 | pool_size = if pool_size_env, do: String.to_integer(pool_size_env), else: opts[:pool_size] |
| 75 | 0 | ca_cert = System.get_env("HEXPM_DATABASE_CA_CERT") |
| 76 | 0 | client_key = System.get_env("HEXPM_DATABASE_CLIENT_KEY") |
| 77 | 0 | client_cert = System.get_env("HEXPM_DATABASE_CLIENT_CERT") |
| 78 | ||
| 79 | 0 | ssl_opts = |
| 80 | 0 | if ca_cert do |
| 81 | [ | |
| 82 | cacerts: [decode_cert(ca_cert)], | |
| 83 | key: decode_key(client_key), | |
| 84 | cert: decode_cert(client_cert) | |
| 85 | ] | |
| 86 | end | |
| 87 | ||
| 88 | 0 | opts = |
| 89 | opts | |
| 90 | |> Keyword.put(:ssl_opts, ssl_opts) | |
| 91 | |> Keyword.put(:url, url) | |
| 92 | |> Keyword.put(:pool_size, pool_size) | |
| 93 | ||
| 94 | {:ok, opts} | |
| 95 | else | |
| 96 | {:ok, opts} | |
| 97 | end | |
| 98 | end | |
| 99 | ||
| 100 | defp decode_cert(cert) do | |
| 101 | 0 | [{:Certificate, der, _}] = :public_key.pem_decode(cert) |
| 102 | 0 | der |
| 103 | end | |
| 104 | ||
| 105 | defp decode_key(cert) do | |
| 106 | 0 | [{:RSAPrivateKey, key, :not_encrypted}] = :public_key.pem_decode(cert) |
| 107 | {:RSAPrivateKey, key} | |
| 108 | end | |
| 109 | ||
| 110 | def refresh_view(schema) do | |
| 111 | 54 | source = schema.__schema__(:source) |
| 112 | 54 | query = ~s(REFRESH MATERIALIZED VIEW CONCURRENTLY "#{source}") |
| 113 | ||
| 114 | 54 | {:ok, _} = Hexpm.Repo.query(query, []) |
| 115 | :ok | |
| 116 | end | |
| 117 | ||
| 118 | def try_advisory_xact_lock?(key, opts \\ []) do | |
| 119 | 0 | %Postgrex.Result{rows: [[result]]} = |
| 120 | query!( | |
| 121 | "SELECT pg_try_advisory_xact_lock($1)", | |
| 122 | [Map.fetch!(@advisory_locks, key)], | |
| 123 | opts | |
| 124 | ) | |
| 125 | ||
| 126 | 0 | result |
| 127 | end | |
| 128 | ||
| 129 | def try_advisory_lock?(key, opts \\ []) do | |
| 130 | 48 | %Postgrex.Result{rows: [[result]]} = |
| 131 | query!( | |
| 132 | "SELECT pg_try_advisory_lock($1)", | |
| 133 | [Map.fetch!(@advisory_locks, key)], | |
| 134 | opts | |
| 135 | ) | |
| 136 | ||
| 137 | 48 | result |
| 138 | end | |
| 139 | ||
| 140 | def advisory_unlock(key, opts \\ []) do | |
| 141 | 48 | %Postgrex.Result{rows: [[true]]} = |
| 142 | query!( | |
| 143 | "SELECT pg_advisory_unlock($1)", | |
| 144 | [Map.fetch!(@advisory_locks, key)], | |
| 145 | opts | |
| 146 | ) | |
| 147 | ||
| 148 | :ok | |
| 149 | end | |
| 150 | end | |
| 151 | ||
| 152 | defmodule Hexpm.WriteInReadOnlyMode do | |
| 153 | defexception [] | |
| 154 | ||
| 155 | 1 | def message(_) do |
| 156 | "tried to write in read-only mode" | |
| 157 | end | |
| 158 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.Assets do | |
| 1 | alias Hexpm.Repository.Repository | |
| 2 | ||
| 3 | def push_release(release, body) do | |
| 4 | 28 | meta = [ |
| 5 | {"surrogate-key", tarball_cdn_key(release)}, | |
| 6 | {"surrogate-control", "public, max-age=604800"} | |
| 7 | ] | |
| 8 | ||
| 9 | 28 | cache_control = tarball_cache_control(release.package.repository) |
| 10 | 28 | opts = [cache_control: cache_control, meta: meta] |
| 11 | ||
| 12 | 28 | Hexpm.Store.put(:repo_bucket, tarball_store_key(release), body, opts) |
| 13 | 28 | Hexpm.CDN.purge_key(:fastly_hexrepo, tarball_cdn_key(release)) |
| 14 | end | |
| 15 | ||
| 16 | def revert_release(release) do | |
| 17 | 9 | Hexpm.CDN.purge_key(:fastly_hexrepo, tarball_cdn_key(release)) |
| 18 | 9 | Hexpm.Store.delete(:repo_bucket, tarball_store_key(release)) |
| 19 | 9 | revert_docs(release) |
| 20 | end | |
| 21 | ||
| 22 | def push_docs(release, body) do | |
| 23 | 7 | meta = [ |
| 24 | {"surrogate-key", docs_cdn_key(release)}, | |
| 25 | {"surrogate-control", "public, max-age=604800"} | |
| 26 | ] | |
| 27 | ||
| 28 | 7 | cache_control = docs_cache_control(release.package.repository) |
| 29 | 7 | opts = [cache_control: cache_control, meta: meta] |
| 30 | ||
| 31 | 7 | Hexpm.Store.put(:repo_bucket, docs_store_key(release), body, opts) |
| 32 | 7 | Hexpm.CDN.purge_key(:fastly_hexrepo, docs_cdn_key(release)) |
| 33 | end | |
| 34 | ||
| 35 | def revert_docs(release) do | |
| 36 | 11 | if release.has_docs do |
| 37 | 5 | Hexpm.Store.delete(:repo_bucket, docs_store_key(release)) |
| 38 | 5 | Hexpm.CDN.purge_key(:fastly_hexrepo, docs_cdn_key(release)) |
| 39 | end | |
| 40 | end | |
| 41 | ||
| 42 | 20 | defp tarball_cache_control(%Repository{id: 1}), do: "public, max-age=604800" |
| 43 | 8 | defp tarball_cache_control(%Repository{}), do: "private, max-age=86400" |
| 44 | ||
| 45 | 3 | defp docs_cache_control(%Repository{id: 1}), do: "public, max-age=86400" |
| 46 | 4 | defp docs_cache_control(%Repository{}), do: "private, max-age=86400" |
| 47 | ||
| 48 | def tarball_cdn_key(release) do | |
| 49 | 65 | "tarballs/#{repository_cdn_key(release)}#{release.package.name}-#{release.version}" |
| 50 | end | |
| 51 | ||
| 52 | def tarball_store_key(release) do | |
| 53 | 37 | "#{repository_store_key(release)}tarballs/#{release.package.name}-#{release.version}.tar" |
| 54 | end | |
| 55 | ||
| 56 | def docs_cdn_key(release) do | |
| 57 | 19 | "docs/#{repository_cdn_key(release)}#{release.package.name}-#{release.version}" |
| 58 | end | |
| 59 | ||
| 60 | def docs_store_key(release) do | |
| 61 | 12 | "#{repository_store_key(release)}docs/#{release.package.name}-#{release.version}.tar.gz" |
| 62 | end | |
| 63 | ||
| 64 | defp repository_cdn_key(release) do | |
| 65 | 84 | repository = release.package.repository |
| 66 | ||
| 67 | 84 | if repository.id == 1 do |
| 68 | "" | |
| 69 | else | |
| 70 | 27 | "#{repository.name}-" |
| 71 | end | |
| 72 | end | |
| 73 | ||
| 74 | defp repository_store_key(release) do | |
| 75 | 49 | repository = release.package.repository |
| 76 | ||
| 77 | 49 | if repository.id == 1 do |
| 78 | "" | |
| 79 | else | |
| 80 | 15 | "repos/#{repository.name}/" |
| 81 | end | |
| 82 | end | |
| 83 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.Download do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | @derive HexpmWeb.Stale | |
| 4 | ||
| 5 | 585 | schema "downloads" do |
| 6 | belongs_to :package, Package | |
| 7 | belongs_to :release, Release | |
| 8 | field :downloads, :integer | |
| 9 | field :day, :date | |
| 10 | field :updated_at, :utc_datetime_usec, virtual: true | |
| 11 | end | |
| 12 | ||
| 13 | defmacrop date_trunc(period, expr) do | |
| 14 | quote do | |
| 15 | fragment("date_trunc(?, ?)", unquote(period), unquote(expr)) | |
| 16 | end | |
| 17 | end | |
| 18 | ||
| 19 | defmacrop date_trunc_format(period, format, expr) do | |
| 20 | quote do | |
| 21 | fragment("to_char(date_trunc(?, ?), ?)", unquote(period), unquote(expr), unquote(format)) | |
| 22 | end | |
| 23 | end | |
| 24 | ||
| 25 | def query_filter(query, filter) do | |
| 26 | 17 | case filter do |
| 27 | :day -> | |
| 28 | 10 | from( |
| 29 | d in query, | |
| 30 | group_by: d.day, | |
| 31 | order_by: d.day, | |
| 32 | select: %Download{ | |
| 33 | day: date_trunc_format("day", "YYYY-MM-DD", d.day), | |
| 34 | downloads: sum(d.downloads), | |
| 35 | updated_at: max(d.day) | |
| 36 | } | |
| 37 | ) | |
| 38 | ||
| 39 | :month -> | |
| 40 | 1 | from( |
| 41 | d in query, | |
| 42 | group_by: date_trunc("month", d.day), | |
| 43 | order_by: date_trunc("month", d.day), | |
| 44 | select: %Download{ | |
| 45 | day: date_trunc_format("month", "YYYY-MM", d.day), | |
| 46 | downloads: sum(d.downloads), | |
| 47 | updated_at: max(d.day) | |
| 48 | } | |
| 49 | ) | |
| 50 | ||
| 51 | :all -> | |
| 52 | 6 | from( |
| 53 | d in query, | |
| 54 | select: %Download{ | |
| 55 | downloads: sum(d.downloads), | |
| 56 | updated_at: max(d.day) | |
| 57 | } | |
| 58 | ) | |
| 59 | end | |
| 60 | end | |
| 61 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.Install do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | 54 | schema "installs" do |
| 4 | field :hex, :string | |
| 5 | field :elixirs, {:array, :string} | |
| 6 | end | |
| 7 | ||
| 8 | def all() do | |
| 9 | 9 | from(i in Install, order_by: [asc: i.id]) |
| 10 | end | |
| 11 | ||
| 12 | def latest(all, current) do | |
| 13 | 9 | case Version.parse(current) do |
| 14 | {:ok, current} -> | |
| 15 | 9 | installs = |
| 16 | Enum.filter(all, fn %Install{elixirs: elixirs} -> | |
| 17 | 54 | Enum.any?(elixirs, &(Version.compare(&1, current) != :gt)) |
| 18 | end) | |
| 19 | ||
| 20 | 9 | elixir = |
| 21 | 1 | if install = List.last(installs) do |
| 22 | 8 | install.elixirs |
| 23 | 18 | |> Enum.filter(&(Version.compare(&1, current) != :gt)) |
| 24 | 8 | |> List.last() |
| 25 | end | |
| 26 | ||
| 27 | 9 | if elixir do |
| 28 | 8 | {:ok, install.hex, elixir} |
| 29 | else | |
| 30 | :error | |
| 31 | end | |
| 32 | ||
| 33 | 0 | :error -> |
| 34 | :error | |
| 35 | end | |
| 36 | end | |
| 37 | ||
| 38 | def build(hex, elixirs) do | |
| 39 | 6 | change(%Hexpm.Repository.Install{}, hex: hex, elixirs: elixirs) |
| 40 | end | |
| 41 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.Installs do | |
| 1 | use Hexpm.Context | |
| 2 | ||
| 3 | def all() do | |
| 4 | 9 | Repo.all(Install.all()) |
| 5 | end | |
| 6 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.Owners do | |
| 1 | use Hexpm.Context | |
| 2 | ||
| 3 | def all(package, preload \\ []) do | |
| 4 | assoc(package, :package_owners) | |
| 5 | |> Repo.all() | |
| 6 | 40 | |> Repo.preload(preload) |
| 7 | end | |
| 8 | ||
| 9 | def get(package, user) do | |
| 10 | 25 | if owner = Repo.get_by(PackageOwner, package_id: package.id, user_id: user.id) do |
| 11 | 9 | %{owner | package: package, user: user} |
| 12 | end | |
| 13 | end | |
| 14 | ||
| 15 | def add(package, user, params, audit: audit_data) do | |
| 16 | 12 | repository = package.repository |
| 17 | 12 | owners = all(package, user: [:emails, :organization]) |
| 18 | 12 | repository_access = Organizations.access?(repository.organization, user, "read") |
| 19 | ||
| 20 | 12 | cond do |
| 21 | 12 | repository.id != 1 and not repository_access -> |
| 22 | {:error, :not_member} | |
| 23 | ||
| 24 | 11 | User.organization?(user) and Map.get(params, "transfer", false) != true -> |
| 25 | {:error, :not_organization_transfer} | |
| 26 | ||
| 27 | 10 | User.organization?(user) and Map.get(params, "level", "full") != "full" -> |
| 28 | {:error, :organization_level} | |
| 29 | ||
| 30 | 10 | not User.organization?(user) && Organizations.get(user.username) -> |
| 31 | {:error, :organization_user_conflict} | |
| 32 | ||
| 33 | 10 | true -> |
| 34 | 10 | add_owner(package, owners, user, params, audit_data) |
| 35 | end | |
| 36 | end | |
| 37 | ||
| 38 | defp add_owner(package, owners, user, params, audit_data) do | |
| 39 | 10 | owner = Enum.find(owners, &(&1.user_id == user.id)) |
| 40 | 10 | owner = owner || %PackageOwner{package_id: package.id, user_id: user.id} |
| 41 | 10 | changeset = PackageOwner.changeset(owner, params) |
| 42 | ||
| 43 | 10 | multi = |
| 44 | Multi.new() | |
| 45 | |> Multi.insert_or_update(:owner, changeset) | |
| 46 | |> remove_existing_owners(owners, params) | |
| 47 | |> audit(audit_data, add_owner_audit_log_action(params), fn %{owner: owner} -> | |
| 48 | 10 | {package, owner.level, user} |
| 49 | end) | |
| 50 | ||
| 51 | 10 | case Repo.transaction(multi) do |
| 52 | {:ok, %{owner: owner}} -> | |
| 53 | # TODO: Separate email for the affected person | |
| 54 | 10 | owners = |
| 55 | owners | |
| 56 | 11 | |> Enum.map(& &1.user) |
| 57 | |> Kernel.++([user]) | |
| 58 | |> Repo.preload(organization: [organization_users: [user: :emails]]) | |
| 59 | ||
| 60 | Emails.owner_added(package, owners, user) | |
| 61 | 10 | |> Mailer.deliver_later!() |
| 62 | ||
| 63 | {:ok, %{owner | user: user}} | |
| 64 | ||
| 65 | 0 | {:error, :owner, changeset, _} -> |
| 66 | {:error, changeset} | |
| 67 | end | |
| 68 | end | |
| 69 | ||
| 70 | 2 | defp add_owner_audit_log_action(%{"transfer" => true}), do: "owner.transfer" |
| 71 | 8 | defp add_owner_audit_log_action(_params), do: "owner.add" |
| 72 | ||
| 73 | defp remove_existing_owners(multi, owners, %{"transfer" => true}) do | |
| 74 | 2 | Multi.run(multi, :removed_owners, fn repo, %{owner: owner} -> |
| 75 | 2 | owner_ids = |
| 76 | owners | |
| 77 | 2 | |> Enum.filter(&(&1.id != owner.id)) |
| 78 | 2 | |> Enum.map(& &1.id) |
| 79 | ||
| 80 | 2 | {num_rows, _} = |
| 81 | from(po in PackageOwner, where: po.id in ^owner_ids) | |
| 82 | |> repo.delete_all() | |
| 83 | ||
| 84 | {:ok, num_rows} | |
| 85 | end) | |
| 86 | end | |
| 87 | ||
| 88 | defp remove_existing_owners(multi, _owners, _params) do | |
| 89 | 8 | multi |
| 90 | end | |
| 91 | ||
| 92 | def remove(package, user, audit: audit_data) do | |
| 93 | 4 | owners = all(package, user: :emails) |
| 94 | 4 | owner = Enum.find(owners, &(&1.user_id == user.id)) |
| 95 | ||
| 96 | 4 | cond do |
| 97 | 4 | !owner -> |
| 98 | {:error, :not_owner} | |
| 99 | ||
| 100 | 4 | length(owners) == 1 and package.repository.id == 1 -> |
| 101 | {:error, :last_owner} | |
| 102 | ||
| 103 | 3 | true -> |
| 104 | 3 | multi = |
| 105 | Multi.new() | |
| 106 | |> Multi.delete(:owner, owner) | |
| 107 | |> audit(audit_data, "owner.remove", fn %{owner: owner} -> | |
| 108 | 3 | {package, owner.level, owner.user} |
| 109 | end) | |
| 110 | ||
| 111 | 3 | {:ok, _} = Repo.transaction(multi) |
| 112 | ||
| 113 | # TODO: Separate email for the affected person | |
| 114 | 3 | owners = |
| 115 | owners | |
| 116 | 5 | |> Enum.map(& &1.user) |
| 117 | |> Repo.preload(organization: [users: :emails]) | |
| 118 | ||
| 119 | 3 | Emails.owner_removed(package, owners, owner.user) |
| 120 | 3 | |> Mailer.deliver_later!() |
| 121 | ||
| 122 | :ok | |
| 123 | end | |
| 124 | end | |
| 125 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.Package do | |
| 1 | use Hexpm.Schema | |
| 2 | import Ecto.Query, only: [from: 2] | |
| 3 | ||
| 4 | @derive {HexpmWeb.Stale, assocs: [:releases, :owners, :downloads]} | |
| 5 | @derive {Phoenix.Param, key: :name} | |
| 6 | ||
| 7 | 4329 | schema "packages" do |
| 8 | field :name, :string | |
| 9 | field :docs_updated_at, :utc_datetime_usec | |
| 10 | field :latest_version, Hexpm.Version, virtual: true | |
| 11 | timestamps() | |
| 12 | ||
| 13 | belongs_to :repository, Repository | |
| 14 | has_many :releases, Release | |
| 15 | has_many :package_owners, PackageOwner | |
| 16 | has_many :owners, through: [:package_owners, :user] | |
| 17 | has_many :downloads, PackageDownload | |
| 18 | has_many :package_reports, PackageReport | |
| 19 | embeds_one :meta, PackageMetadata, on_replace: :delete | |
| 20 | end | |
| 21 | ||
| 22 | @elixir_names ~w(eex elixir elixirc ex_unit iex logger mix) | |
| 23 | @tool_names ~w(erlang typer to_erl run_erl escript erlc erl epmd dialyzer ct_run rebar rebar3 hex hexpm mix_hex) | |
| 24 | @otp_names ~w( | |
| 25 | otp asn1 common_test compiler crypto debugger dialyzer diameter | |
| 26 | edoc eldap erl_docgen erl_interface erts et eunit ftp hipe | |
| 27 | inets jinterface kernel megaco mnesia observer odbc os_mon | |
| 28 | parsetools public_key reltool runtime_tools sasl snmp ssh | |
| 29 | ssl stdlib syntax_tools toolbar tools typer wx xmerl | |
| 30 | ) | |
| 31 | @inets_names ~w(tftp httpc httpd) | |
| 32 | @app_names ~w(toucan net http net_http) | |
| 33 | @windows_names ~w( | |
| 34 | nul con prn aux com1 com2 com3 com4 com5 com6 com7 com8 com9 lpt1 lpt2 | |
| 35 | lpt3 lpt4 lpt5 lpt6 lpt7 lpt8 lpt9 | |
| 36 | ) | |
| 37 | @generic_names ~w(package organization www myapp lock locked) | |
| 38 | ||
| 39 | @reserved_names Enum.concat([ | |
| 40 | @elixir_names, | |
| 41 | @otp_names, | |
| 42 | @inets_names, | |
| 43 | @tool_names, | |
| 44 | @app_names, | |
| 45 | @windows_names, | |
| 46 | @generic_names | |
| 47 | ]) | |
| 48 | ||
| 49 | def build(repository, user, params) do | |
| 50 | 29 | package = |
| 51 | build_assoc(repository, :packages) | |
| 52 | |> Map.put(:repository, repository) | |
| 53 | ||
| 54 | package | |
| 55 | |> cast(params, ~w(name)a) | |
| 56 | |> unique_constraint(:name, name: :packages_repository_id_name_index) | |
| 57 | |> validate_required(:name) | |
| 58 | |> validate_length(:name, min: 2) | |
| 59 | |> validate_format(:name, ~r"^[a-z]\w*$") | |
| 60 | |> validate_exclusion(:name, @reserved_names) | |
| 61 | 29 | |> cast_embed(:meta, with: &PackageMetadata.changeset(&1, &2, package), required: true) |
| 62 | 29 | |> put_first_owner(user, repository) |
| 63 | end | |
| 64 | ||
| 65 | @spec delete({map, any} | %{__struct__: atom | %{__changeset__: any}}) :: Ecto.Changeset.t() | |
| 66 | def delete(package) do | |
| 67 | 7 | foreign_key_constraint( |
| 68 | change(package), | |
| 69 | :name, | |
| 70 | name: "requirements_dependency_id_fkey", | |
| 71 | message: "you cannot delete this package because other packages depend on it" | |
| 72 | ) | |
| 73 | end | |
| 74 | ||
| 75 | defp put_first_owner(changeset, user, repository) do | |
| 76 | 29 | if repository.id == 1 do |
| 77 | 16 | put_assoc(changeset, :package_owners, [%PackageOwner{user_id: user.id}]) |
| 78 | else | |
| 79 | 13 | changeset |
| 80 | end | |
| 81 | end | |
| 82 | ||
| 83 | def update(package, params) do | |
| 84 | cast(package, params, []) | |
| 85 | # A release publish should always update the package's updated_at | |
| 86 | |> force_change(:updated_at, DateTime.utc_now()) | |
| 87 | 27 | |> cast_embed(:meta, with: &PackageMetadata.changeset(&1, &2, package), required: true) |
| 88 | 27 | |> validate_metadata_name() |
| 89 | end | |
| 90 | ||
| 91 | def package_owner(package, user, level \\ "maintainer") do | |
| 92 | 79 | levels = PackageOwner.level_or_higher(level) |
| 93 | ||
| 94 | 79 | from( |
| 95 | po in PackageOwner, | |
| 96 | left_join: ou in OrganizationUser, | |
| 97 | 79 | on: ou.organization_id == ^package.repository.organization_id, |
| 98 | 79 | where: ou.user_id == ^user.id or ^(package.repository.id == 1), |
| 99 | 79 | where: po.package_id == ^package.id, |
| 100 | 79 | where: po.user_id == ^user.id, |
| 101 | where: po.level in ^levels, | |
| 102 | select: count(po.id) >= 1 | |
| 103 | ) | |
| 104 | end | |
| 105 | ||
| 106 | def organization_owner(package, user, level \\ "maintainer") do | |
| 107 | 24 | role = PackageOwner.level_to_organization_role(level) |
| 108 | 24 | roles = Organization.role_or_higher(role) |
| 109 | ||
| 110 | 24 | from( |
| 111 | po in PackageOwner, | |
| 112 | join: u in assoc(po, :user), | |
| 113 | join: ou in OrganizationUser, | |
| 114 | on: u.organization_id == ou.organization_id, | |
| 115 | 24 | where: po.package_id == ^package.id, |
| 116 | 24 | where: ou.user_id == ^user.id, |
| 117 | where: ou.role in ^roles, | |
| 118 | select: count(po.id) >= 1 | |
| 119 | ) | |
| 120 | end | |
| 121 | ||
| 122 | def all(repositories, page, count, search, sort, fields) do | |
| 123 | 39 | from( |
| 124 | p in assoc(repositories, :packages), | |
| 125 | join: r in assoc(p, :repository), | |
| 126 | preload: :downloads | |
| 127 | ) | |
| 128 | |> sort(sort) | |
| 129 | |> Hexpm.Utils.paginate(page, count) | |
| 130 | |> search(search) | |
| 131 | 39 | |> fields(fields) |
| 132 | end | |
| 133 | ||
| 134 | def recent(repository, count) do | |
| 135 | 2 | from( |
| 136 | p in assoc(repository, :packages), | |
| 137 | order_by: [desc: p.inserted_at], | |
| 138 | limit: ^count, | |
| 139 | select: {p.name, p.inserted_at, p.meta} | |
| 140 | ) | |
| 141 | end | |
| 142 | ||
| 143 | def count() do | |
| 144 | 2 | from(p in Package, select: count(p.id)) |
| 145 | end | |
| 146 | ||
| 147 | def count(repositories, search) do | |
| 148 | 17 | from( |
| 149 | p in assoc(repositories, :packages), | |
| 150 | join: r in assoc(p, :repository), | |
| 151 | select: count(p.id) | |
| 152 | ) | |
| 153 | 17 | |> search(search) |
| 154 | end | |
| 155 | ||
| 156 | defp validate_metadata_name(changeset) do | |
| 157 | 27 | name = get_field(changeset, :name) |
| 158 | 27 | meta_name = changeset.params["meta"]["name"] |
| 159 | ||
| 160 | 27 | if !meta_name || name == meta_name do |
| 161 | 26 | changeset |
| 162 | else | |
| 163 | 1 | add_error(changeset, :name, "metadata does not match package name") |
| 164 | end | |
| 165 | end | |
| 166 | ||
| 167 | defp fields(query, nil) do | |
| 168 | 30 | query |
| 169 | end | |
| 170 | ||
| 171 | defp fields(query, fields) do | |
| 172 | 9 | from(p in query, select: ^fields) |
| 173 | end | |
| 174 | ||
| 175 | defmacrop description_query(p, search) do | |
| 176 | quote do | |
| 177 | fragment( | |
| 178 | "to_tsvector('english', regexp_replace((?->'description')::text, '/', ' ')) @@ to_tsquery('english', ?)", | |
| 179 | unquote(p).meta, | |
| 180 | ^unquote(search) | |
| 181 | ) | |
| 182 | end | |
| 183 | end | |
| 184 | ||
| 185 | defmacrop name_query(p, search) do | |
| 186 | quote do | |
| 187 | ilike(fragment("?::text", unquote(p).name), ^unquote(search)) | |
| 188 | end | |
| 189 | end | |
| 190 | ||
| 191 | defp search(query, nil) do | |
| 192 | 14 | query |
| 193 | end | |
| 194 | ||
| 195 | defp search(query, {:letter, letter}) do | |
| 196 | 4 | search = letter <> "%" |
| 197 | 4 | from(p in query, where: name_query(p, search)) |
| 198 | end | |
| 199 | ||
| 200 | defp search(query, search) when is_binary(search) do | |
| 201 | 38 | case parse_search(search) do |
| 202 | {:ok, params} -> | |
| 203 | 25 | Enum.reduce(params, query, fn {k, v}, q -> search_param(k, v, q) end) |
| 204 | ||
| 205 | :error -> | |
| 206 | 13 | basic_search(query, search) |
| 207 | end | |
| 208 | end | |
| 209 | ||
| 210 | defp basic_search(query, search) do | |
| 211 | 13 | {repository, package} = name_search(search) |
| 212 | 13 | description = description_search(search) |
| 213 | ||
| 214 | 13 | if repository do |
| 215 | 3 | from( |
| 216 | [p, r] in query, | |
| 217 | where: | |
| 218 | (name_query(p, package) and name_query(r, repository)) or | |
| 219 | description_query(p, description) | |
| 220 | ) | |
| 221 | else | |
| 222 | 10 | from(p in query, where: name_query(p, package) or description_query(p, description)) |
| 223 | end | |
| 224 | end | |
| 225 | ||
| 226 | # TODO: add repository param | |
| 227 | defp search_param("name", search, query) do | |
| 228 | 4 | case String.split(search, "/", parts: 2) do |
| 229 | [repository, package] -> | |
| 230 | 0 | from( |
| 231 | [p, r] in query, | |
| 232 | where: name_query(p, extra_name_search(package)), | |
| 233 | where: name_query(r, extra_name_search(repository)) | |
| 234 | ) | |
| 235 | ||
| 236 | _ -> | |
| 237 | 4 | search = extra_name_search(search) |
| 238 | 4 | from(p in query, where: name_query(p, search)) |
| 239 | end | |
| 240 | end | |
| 241 | ||
| 242 | defp search_param("description", search, query) do | |
| 243 | 0 | search = description_search(search) |
| 244 | 0 | from(p in query, where: description_query(p, search)) |
| 245 | end | |
| 246 | ||
| 247 | defp search_param("extra", search, query) do | |
| 248 | 3 | [value | keys] = |
| 249 | search | |
| 250 | |> String.split(",") | |
| 251 | |> Enum.reverse() | |
| 252 | ||
| 253 | 3 | extra = extra_map(keys, extra_value(value)) |
| 254 | ||
| 255 | 3 | from(p in query, where: fragment("?->'extra' @> ?", p.meta, ^extra)) |
| 256 | end | |
| 257 | ||
| 258 | defp search_param("depends", search, query) do | |
| 259 | 22 | case String.split(search, ":", parts: 2) do |
| 260 | [repository, package] -> | |
| 261 | 22 | from( |
| 262 | p in query, | |
| 263 | join: pd in Hexpm.Repository.PackageDependant, | |
| 264 | on: p.id == pd.dependant_id, | |
| 265 | where: pd.name == ^package, | |
| 266 | where: pd.repo == ^repository | |
| 267 | ) | |
| 268 | ||
| 269 | _ -> | |
| 270 | 0 | from( |
| 271 | p in query, | |
| 272 | join: pd in Hexpm.Repository.PackageDependant, | |
| 273 | on: p.id == pd.dependant_id, | |
| 274 | where: pd.name == ^search | |
| 275 | ) | |
| 276 | end | |
| 277 | end | |
| 278 | ||
| 279 | defp search_param(_, _, query) do | |
| 280 | 0 | query |
| 281 | end | |
| 282 | ||
| 283 | defp extra_value(<<"[", value::binary>>) do | |
| 284 | value | |
| 285 | |> String.trim_trailing("]") | |
| 286 | |> String.split(",") | |
| 287 | 2 | |> Enum.map(&try_integer/1) |
| 288 | end | |
| 289 | ||
| 290 | 1 | defp extra_value(value), do: try_integer(value) |
| 291 | ||
| 292 | defp try_integer(string) do | |
| 293 | 3 | case Integer.parse(string) do |
| 294 | 1 | {int, ""} -> int |
| 295 | 2 | _ -> string |
| 296 | end | |
| 297 | end | |
| 298 | ||
| 299 | 3 | defp extra_map([], m), do: m |
| 300 | ||
| 301 | defp extra_map([h | t], m) do | |
| 302 | 4 | extra_map(t, %{h => m}) |
| 303 | end | |
| 304 | ||
| 305 | 16 | defp like_search(search, :contains), do: "%" <> search <> "%" |
| 306 | 0 | defp like_search(search, :equals), do: search |
| 307 | ||
| 308 | defp escape_search(search) do | |
| 309 | 20 | String.replace(search, ~r"(%|_|\\)"u, "\\\\\\1") |
| 310 | end | |
| 311 | ||
| 312 | defp name_search(search) do | |
| 313 | 13 | case String.split(search, "/", parts: 2) do |
| 314 | 3 | [repository, package] -> |
| 315 | {do_name_search(repository), do_name_search(package)} | |
| 316 | ||
| 317 | 10 | _ -> |
| 318 | {nil, do_name_search(search)} | |
| 319 | end | |
| 320 | end | |
| 321 | ||
| 322 | defp do_name_search(search) do | |
| 323 | search | |
| 324 | |> escape_search() | |
| 325 | 16 | |> like_search(search_filter(search)) |
| 326 | end | |
| 327 | ||
| 328 | defp search_filter(search) do | |
| 329 | 16 | if String.length(search) >= 3 do |
| 330 | :contains | |
| 331 | else | |
| 332 | :equals | |
| 333 | end | |
| 334 | end | |
| 335 | ||
| 336 | defp description_search(search) do | |
| 337 | search | |
| 338 | |> String.replace(~r/\//u, " ") | |
| 339 | |> String.replace(~r/[^\w\s]/u, "") | |
| 340 | |> String.trim() | |
| 341 | 13 | |> String.replace(~r"\s+"u, " | ") |
| 342 | end | |
| 343 | ||
| 344 | def extra_name_search(search) do | |
| 345 | search | |
| 346 | |> escape_search() | |
| 347 | 4 | |> String.replace(~r/(^\*)|(\*$)/u, "%") |
| 348 | end | |
| 349 | ||
| 350 | defp sort(query, :name) do | |
| 351 | 10 | from(p in query, order_by: p.name) |
| 352 | end | |
| 353 | ||
| 354 | defp sort(query, :inserted_at) do | |
| 355 | 1 | from(p in query, order_by: [desc: p.inserted_at]) |
| 356 | end | |
| 357 | ||
| 358 | defp sort(query, :updated_at) do | |
| 359 | 1 | from(p in query, order_by: [desc: p.updated_at]) |
| 360 | end | |
| 361 | ||
| 362 | defp sort(query, :total_downloads) do | |
| 363 | 1 | from( |
| 364 | p in query, | |
| 365 | left_join: d in PackageDownload, | |
| 366 | on: p.id == d.package_id and d.view == "all", | |
| 367 | order_by: [fragment("? DESC NULLS LAST", d.downloads)] | |
| 368 | ) | |
| 369 | end | |
| 370 | ||
| 371 | defp sort(query, :recent_downloads) do | |
| 372 | 18 | from( |
| 373 | p in query, | |
| 374 | left_join: d in PackageDownload, | |
| 375 | on: p.id == d.package_id and d.view == "recent", | |
| 376 | order_by: [fragment("? DESC NULLS LAST", d.downloads)] | |
| 377 | ) | |
| 378 | end | |
| 379 | ||
| 380 | defp sort(query, nil) do | |
| 381 | 8 | query |
| 382 | end | |
| 383 | ||
| 384 | defp parse_search(search) do | |
| 385 | search | |
| 386 | |> String.trim_leading() | |
| 387 | 38 | |> parse_params([]) |
| 388 | end | |
| 389 | ||
| 390 | 25 | defp parse_params("", params), do: {:ok, Enum.reverse(params)} |
| 391 | ||
| 392 | defp parse_params(tail, params) do | |
| 393 | 42 | with {:ok, key, tail} <- parse_key(tail), |
| 394 | 29 | {:ok, value, tail} <- parse_value(tail) do |
| 395 | 29 | parse_params(tail, [{key, value} | params]) |
| 396 | else | |
| 397 | _ -> :error | |
| 398 | end | |
| 399 | end | |
| 400 | ||
| 401 | defp parse_key(string) do | |
| 402 | 42 | with [k, tail] when k != "" <- String.split(string, ":", parts: 2) do |
| 403 | 29 | {:ok, k, String.trim_leading(tail)} |
| 404 | end | |
| 405 | end | |
| 406 | ||
| 407 | defp parse_value(string) do | |
| 408 | 29 | case string do |
| 409 | "\"" <> rest -> | |
| 410 | 0 | with [v, tail] <- String.split(rest, "\"", parts: 2) do |
| 411 | 0 | {:ok, v, String.trim_leading(tail)} |
| 412 | end | |
| 413 | ||
| 414 | _ -> | |
| 415 | 29 | case String.split(string, " ", parts: 2) do |
| 416 | 25 | [value] -> {:ok, value, ""} |
| 417 | 4 | [value, tail] -> {:ok, value, String.trim_leading(tail)} |
| 418 | end | |
| 419 | end | |
| 420 | end | |
| 421 | ||
| 422 | def downloads_for_last_n_days(package_id, num_of_days) do | |
| 423 | 5 | date_start = Date.add(Date.utc_today(), -1 * num_of_days) |
| 424 | 5 | from(d in downloads_by_period(package_id, :day), where: d.day >= ^date_start) |
| 425 | end | |
| 426 | ||
| 427 | def downloads_by_period(package_id, filter) do | |
| 428 | from(d in Download, where: d.package_id == ^package_id) | |
| 429 | 5 | |> Download.query_filter(filter) |
| 430 | end | |
| 431 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.PackageDependant do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | 61 | schema "package_dependants" do |
| 4 | belongs_to :package, Package | |
| 5 | field :name, :string | |
| 6 | field :repo, :string | |
| 7 | end | |
| 8 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.PackageDownload do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | @derive HexpmWeb.Stale | |
| 4 | @primary_key false | |
| 5 | ||
| 6 | 633 | schema "package_downloads" do |
| 7 | belongs_to(:package, Package, references: :id) | |
| 8 | field :view, :string | |
| 9 | field :downloads, :integer | |
| 10 | end | |
| 11 | ||
| 12 | def top(repository, view, count) do | |
| 13 | 2 | from( |
| 14 | pd in PackageDownload, | |
| 15 | join: p in assoc(pd, :package), | |
| 16 | 2 | where: p.repository_id == ^repository.id, |
| 17 | where: pd.view == ^view, | |
| 18 | order_by: [fragment("? DESC NULLS LAST", pd.downloads)], | |
| 19 | limit: ^count, | |
| 20 | select: {p, pd.downloads} | |
| 21 | ) | |
| 22 | end | |
| 23 | ||
| 24 | def total() do | |
| 25 | 2 | from( |
| 26 | pd in PackageDownload, | |
| 27 | where: is_nil(pd.package_id), | |
| 28 | select: {pd.view, coalesce(pd.downloads, 0)} | |
| 29 | ) | |
| 30 | end | |
| 31 | ||
| 32 | def package(package) do | |
| 33 | 9 | from( |
| 34 | pd in PackageDownload, | |
| 35 | 9 | where: pd.package_id == ^package.id, |
| 36 | select: {pd.view, coalesce(pd.downloads, 0)} | |
| 37 | ) | |
| 38 | end | |
| 39 | ||
| 40 | def packages_and_all_download_views(packages) do | |
| 41 | 12 | package_ids = Enum.map(packages, & &1.id) |
| 42 | ||
| 43 | 12 | from( |
| 44 | pd in PackageDownload, | |
| 45 | join: p in assoc(pd, :package), | |
| 46 | where: pd.package_id in ^package_ids, | |
| 47 | select: {p.id, pd.view, coalesce(pd.downloads, 0)} | |
| 48 | ) | |
| 49 | end | |
| 50 | ||
| 51 | def packages(packages, view) do | |
| 52 | 0 | package_ids = Enum.map(packages, & &1.id) |
| 53 | ||
| 54 | 0 | from( |
| 55 | pd in PackageDownload, | |
| 56 | join: p in assoc(pd, :package), | |
| 57 | where: pd.package_id in ^package_ids, | |
| 58 | where: pd.view == ^view, | |
| 59 | select: {p.id, pd.downloads} | |
| 60 | ) | |
| 61 | end | |
| 62 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.PackageMetadata do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | @derive HexpmWeb.Stale | |
| 4 | ||
| 5 | 3588 | embedded_schema do |
| 6 | field :description, :string | |
| 7 | field :licenses, {:array, :string} | |
| 8 | field :links, {:map, :string} | |
| 9 | field :maintainers, {:array, :string} | |
| 10 | field :extra, :map | |
| 11 | end | |
| 12 | ||
| 13 | def changeset(meta, params, package) do | |
| 14 | cast(meta, params, ~w(description licenses links maintainers extra)a) | |
| 15 | |> validate_required_meta(package) | |
| 16 | 56 | |> validate_links() |
| 17 | end | |
| 18 | ||
| 19 | defp validate_required_meta(changeset, package) do | |
| 20 | 56 | if package.repository.id == 1 do |
| 21 | 35 | validate_required(changeset, ~w(description licenses)a) |
| 22 | else | |
| 23 | 21 | changeset |
| 24 | end | |
| 25 | end | |
| 26 | ||
| 27 | defp validate_links(changeset) do | |
| 28 | 56 | validate_change(changeset, :links, fn _, links -> |
| 29 | links | |
| 30 | |> Map.values() | |
| 31 | |> Enum.reject(&valid_url?/1) | |
| 32 | 4 | |> Enum.map(&{:links, "invalid link #{inspect(&1)}"}) |
| 33 | end) | |
| 34 | end | |
| 35 | ||
| 36 | defp valid_url?(url) do | |
| 37 | 8 | uri = URI.parse(url) |
| 38 | 8 | uri.scheme in ["http", "https"] and !!uri.host |
| 39 | end | |
| 40 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.PackageOwner do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | 2141 | schema "package_owners" do |
| 4 | field :level, :string, default: "full" | |
| 5 | ||
| 6 | belongs_to :package, Package | |
| 7 | belongs_to :user, User | |
| 8 | ||
| 9 | timestamps() | |
| 10 | end | |
| 11 | ||
| 12 | @valid_levels ["full", "maintainer"] | |
| 13 | ||
| 14 | def changeset(package_owner, params) do | |
| 15 | cast(package_owner, params, [:level]) | |
| 16 | |> unique_constraint(:user_id, name: "package_owners_unique", message: "is already owner") | |
| 17 | |> validate_required(:level) | |
| 18 | 10 | |> validate_inclusion(:level, @valid_levels) |
| 19 | end | |
| 20 | ||
| 21 | 89 | def level_to_organization_role("maintainer"), do: "write" |
| 22 | 40 | def level_to_organization_role("full"), do: "admin" |
| 23 | ||
| 24 | 54 | def level_or_higher("maintainer"), do: ["maintainer", "full"] |
| 25 | 25 | def level_or_higher("full"), do: ["full"] |
| 26 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.PackageReport do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | @derive Phoenix.Param | |
| 4 | ||
| 5 | 2075 | schema "package_reports" do |
| 6 | field :state, :string, default: "to_accept" | |
| 7 | field :description, :string | |
| 8 | ||
| 9 | belongs_to :author, Hexpm.Accounts.User | |
| 10 | belongs_to :package, Package | |
| 11 | # field :requirement, :string | |
| 12 | has_many :package_report_releases, PackageReportRelease | |
| 13 | has_many :releases, through: [:package_report_releases, :release] | |
| 14 | ||
| 15 | timestamps() | |
| 16 | end | |
| 17 | ||
| 18 | @valid_states ["to_accept", "accepted", "rejected", "solved", "unresolved"] | |
| 19 | ||
| 20 | def build(releases, user, package, params) do | |
| 21 | %PackageReport{} | |
| 22 | |> cast(params, ~w(state description)a) | |
| 23 | |> validate_required(:state) | |
| 24 | |> validate_inclusion(:state, @valid_states) | |
| 25 | |> put_assoc(:package_report_releases, package_report_releases(releases)) | |
| 26 | |> put_assoc(:author, user) | |
| 27 | 6 | |> put_assoc(:package, package) |
| 28 | end | |
| 29 | ||
| 30 | def change_state(report, params) do | |
| 31 | cast(report, params, ~w(state)a) | |
| 32 | |> validate_required(:state) | |
| 33 | 8 | |> validate_inclusion(:state, @valid_states) |
| 34 | end | |
| 35 | ||
| 36 | def get(id) do | |
| 37 | 35 | from( |
| 38 | r in PackageReport, | |
| 39 | preload: :author, | |
| 40 | preload: :package, | |
| 41 | preload: :releases, | |
| 42 | preload: :package_report_releases, | |
| 43 | where: r.id == ^id, | |
| 44 | select: r | |
| 45 | ) | |
| 46 | end | |
| 47 | ||
| 48 | def all() do | |
| 49 | 1 | from( |
| 50 | p in PackageReport, | |
| 51 | preload: :package_report_releases, | |
| 52 | preload: :author, | |
| 53 | preload: :releases, | |
| 54 | preload: :package, | |
| 55 | order_by: [desc: p.updated_at] | |
| 56 | ) | |
| 57 | end | |
| 58 | ||
| 59 | defp package_report_releases(releases) do | |
| 60 | 6 | Enum.map(releases, &%PackageReportRelease{release_id: &1.id}) |
| 61 | end | |
| 62 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.PackageReportComment do | |
| 1 | use Hexpm.Schema | |
| 2 | import Ecto.Query, only: [from: 2] | |
| 3 | ||
| 4 | 15 | schema "package_report_comments" do |
| 5 | field :text, :string | |
| 6 | timestamps() | |
| 7 | ||
| 8 | belongs_to :package_report, PackageReport | |
| 9 | belongs_to :author, User | |
| 10 | end | |
| 11 | ||
| 12 | def build(report, user, params) do | |
| 13 | %PackageReportComment{} | |
| 14 | |> cast(params, ~w(text)a) | |
| 15 | |> validate_required(:text) | |
| 16 | |> validate_required(:text, min: 2) | |
| 17 | |> put_assoc(:author, user) | |
| 18 | 1 | |> put_assoc(:package_report, report) |
| 19 | end | |
| 20 | ||
| 21 | def all_for_report(report_id) do | |
| 22 | 15 | from( |
| 23 | c in PackageReportComment, | |
| 24 | join: r in assoc(c, :package_report), | |
| 25 | preload: :author, | |
| 26 | where: r.id == ^report_id, | |
| 27 | select: c | |
| 28 | ) | |
| 29 | end | |
| 30 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.PackageReportRelease do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | 745 | schema "package_report_releases" do |
| 4 | belongs_to :release, Release | |
| 5 | belongs_to :package_report, PackageReport | |
| 6 | ||
| 7 | timestamps() | |
| 8 | end | |
| 9 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.PackageReports do | |
| 1 | use Hexpm.Context | |
| 2 | ||
| 3 | def add(params) do | |
| 4 | 6 | package_report = |
| 5 | Repo.insert!( | |
| 6 | PackageReport.build( | |
| 7 | params["releases"], | |
| 8 | params["user"], | |
| 9 | params["package"], | |
| 10 | params | |
| 11 | ) | |
| 12 | ) | |
| 13 | ||
| 14 | 6 | Enum.each(Users.get_by_role("moderator"), &email_new_report(package_report, &1)) |
| 15 | ||
| 16 | 6 | package_report |
| 17 | end | |
| 18 | ||
| 19 | def all() do | |
| 20 | PackageReport.all() | |
| 21 | 1 | |> Repo.all() |
| 22 | end | |
| 23 | ||
| 24 | def get(id) do | |
| 25 | PackageReport.get(id) | |
| 26 | 27 | |> Repo.one() |
| 27 | end | |
| 28 | ||
| 29 | def accept(report_id) do | |
| 30 | 4 | report = |
| 31 | PackageReport.get(report_id) | |
| 32 | |> Repo.one() | |
| 33 | |> PackageReport.change_state(%{"state" => "accepted"}) | |
| 34 | |> Repo.update!() | |
| 35 | ||
| 36 | 4 | users = |
| 37 | 4 | Enum.map(Owners.all(report.package, [:user]), & &1.user) ++ |
| 38 | 4 | [report.author] ++ |
| 39 | Users.get_by_role("moderator") | |
| 40 | ||
| 41 | 4 | Enum.each(users, &email_state_change(report, &1)) |
| 42 | end | |
| 43 | ||
| 44 | def reject(report_id) do | |
| 45 | 1 | report = |
| 46 | PackageReport.get(report_id) | |
| 47 | |> Repo.one() | |
| 48 | |> PackageReport.change_state(%{"state" => "rejected"}) | |
| 49 | |> Repo.update!() | |
| 50 | ||
| 51 | 1 | Enum.each( |
| 52 | 1 | [report.author] ++ Users.get_by_role("moderator"), |
| 53 | 2 | &email_state_change(report, &1) |
| 54 | ) | |
| 55 | end | |
| 56 | ||
| 57 | def solve(report_id) do | |
| 58 | 2 | report = |
| 59 | PackageReport.get(report_id) | |
| 60 | |> Repo.one() | |
| 61 | |> PackageReport.change_state(%{"state" => "solved"}) | |
| 62 | |> Repo.update!() | |
| 63 | ||
| 64 | 2 | Enum.each(report.releases, &mark_release/1) |
| 65 | ||
| 66 | 2 | users = |
| 67 | 2 | Enum.map(Owners.all(report.package, [:user]), & &1.user) ++ |
| 68 | Users.get_by_role("moderator") | |
| 69 | ||
| 70 | 2 | Enum.each(users, &email_state_change(report, &1)) |
| 71 | end | |
| 72 | ||
| 73 | def unresolve(report_id) do | |
| 74 | 1 | report = |
| 75 | PackageReport.get(report_id) | |
| 76 | |> Repo.one() | |
| 77 | |> PackageReport.change_state(%{"state" => "unresolved"}) | |
| 78 | |> Repo.update!() | |
| 79 | ||
| 80 | 1 | Enum.each(report.releases, &PackageReports.mark_release/1) |
| 81 | ||
| 82 | 1 | users = |
| 83 | 1 | Enum.map(Owners.all(report.package, [:user]), & &1.user) ++ Users.get_by_role("moderator") |
| 84 | ||
| 85 | 1 | Enum.each(users, &email_state_change(report, &1)) |
| 86 | end | |
| 87 | ||
| 88 | def new_comment(report, author, params) do | |
| 89 | 1 | comment = Repo.insert!(PackageReportComment.build(report, author, params)) |
| 90 | ||
| 91 | 1 | users = |
| 92 | 1 | Enum.map(Owners.all(report.package, [:user]), & &1.user) ++ |
| 93 | [author] ++ | |
| 94 | Users.get_by_role("moderator") | |
| 95 | ||
| 96 | 1 | Enum.each(users, &email_new_comment(comment, report, &1)) |
| 97 | ||
| 98 | 1 | comment |
| 99 | end | |
| 100 | ||
| 101 | def all_comments(report_id) do | |
| 102 | PackageReportComment.all_for_report(report_id) | |
| 103 | 15 | |> Repo.all() |
| 104 | end | |
| 105 | ||
| 106 | def mark_release(release) do | |
| 107 | Release.reported_retire(release) | |
| 108 | 4 | |> Repo.update!() |
| 109 | end | |
| 110 | ||
| 111 | defp email_new_report(package_report, user) do | |
| 112 | user | |
| 113 | |> Hexpm.Repo.preload([:emails]) | |
| 114 | |> Emails.report_submitted( | |
| 115 | 6 | package_report.author.username, |
| 116 | 6 | package_report.package.name, |
| 117 | 6 | package_report.id, |
| 118 | 6 | package_report.inserted_at |
| 119 | ) | |
| 120 | 6 | |> Mailer.deliver_later!() |
| 121 | end | |
| 122 | ||
| 123 | defp email_new_comment(comment, report, user) do | |
| 124 | user | |
| 125 | |> Hexpm.Repo.preload([:emails]) | |
| 126 | |> Emails.report_commented( | |
| 127 | 3 | comment.author.username, |
| 128 | 3 | report.id, |
| 129 | 3 | comment.inserted_at |
| 130 | ) | |
| 131 | 3 | |> Mailer.deliver_later!() |
| 132 | end | |
| 133 | ||
| 134 | defp email_state_change(package_report, user) do | |
| 135 | user | |
| 136 | |> Hexpm.Repo.preload([:emails]) | |
| 137 | |> Emails.report_state_changed( | |
| 138 | 20 | package_report.id, |
| 139 | 20 | package_report.state, |
| 140 | 20 | package_report.updated_at |
| 141 | ) | |
| 142 | 20 | |> Mailer.deliver_later!() |
| 143 | end | |
| 144 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.Packages do | |
| 1 | use Hexpm.Context | |
| 2 | ||
| 3 | def count() do | |
| 4 | 2 | Repo.one!(Package.count()) |
| 5 | end | |
| 6 | ||
| 7 | def count(repositories, filter) do | |
| 8 | 17 | Repo.one!(Package.count(repositories, filter)) |
| 9 | end | |
| 10 | ||
| 11 | def get(repository, name) when is_binary(repository) do | |
| 12 | 0 | repository = Repositories.get(repository) |
| 13 | 0 | repository && get(repository, name) |
| 14 | end | |
| 15 | ||
| 16 | def get(repositories, name) when is_list(repositories) do | |
| 17 | Repo.get_by(assoc(repositories, :packages), name: name) | |
| 18 | 4 | |> Repo.preload(:repository) |
| 19 | end | |
| 20 | ||
| 21 | def get(repository, name) do | |
| 22 | 178 | package = Repo.get_by(assoc(repository, :packages), name: name) |
| 23 | 178 | package && %{package | repository: repository} |
| 24 | end | |
| 25 | ||
| 26 | def owner_with_access?(package, user, level \\ "maintainer") do | |
| 27 | 79 | repository = package.repository |
| 28 | 79 | role = PackageOwner.level_to_organization_role(level) |
| 29 | ||
| 30 | 55 | Repo.one!(Package.package_owner(package, user, level)) or |
| 31 | 79 | Repo.one!(Package.organization_owner(package, user, level)) or |
| 32 | 21 | (repository.id != 1 and Organizations.access?(repository.organization, user, role)) |
| 33 | end | |
| 34 | ||
| 35 | def preload(package) do | |
| 36 | 64 | package = Repo.preload(package, [:downloads, :releases]) |
| 37 | 64 | update_in(package.releases, &Release.sort/1) |
| 38 | end | |
| 39 | ||
| 40 | def attach_versions(packages) do | |
| 41 | 14 | versions = Releases.package_versions(packages) |
| 42 | ||
| 43 | 14 | Enum.map(packages, fn package -> |
| 44 | 20 | version = |
| 45 | 20 | Release.latest_version(versions[package.id], only_stable: true, unstable_fallback: true) |
| 46 | ||
| 47 | 20 | %{package | latest_version: version} |
| 48 | end) | |
| 49 | end | |
| 50 | ||
| 51 | def search(repositories, page, packages_per_page, query, sort, fields) do | |
| 52 | Package.all(repositories, page, packages_per_page, query, sort, fields) | |
| 53 | |> Repo.all() | |
| 54 | 17 | |> attach_repositories(repositories) |
| 55 | end | |
| 56 | ||
| 57 | def search_with_versions(repositories, page, packages_per_page, query, sort) do | |
| 58 | Package.all(repositories, page, packages_per_page, query, sort, nil) | |
| 59 | 10 | |> Ecto.Query.preload( |
| 60 | releases: ^from(r in Release, select: struct(r, [:id, :version, :updated_at, :has_docs])) | |
| 61 | ) | |
| 62 | |> Repo.all() | |
| 63 | 20 | |> Enum.map(fn package -> update_in(package.releases, &Release.sort/1) end) |
| 64 | 10 | |> attach_repositories(repositories) |
| 65 | end | |
| 66 | ||
| 67 | defp attach_repositories(packages, repositories) do | |
| 68 | 27 | repositories = Map.new(repositories, &{&1.id, &1}) |
| 69 | ||
| 70 | 27 | Enum.map(packages, fn package -> |
| 71 | 31 | repository = Map.fetch!(repositories, package.repository_id) |
| 72 | 31 | %{package | repository: repository} |
| 73 | end) | |
| 74 | end | |
| 75 | ||
| 76 | def recent(repository, count) do | |
| 77 | 2 | Repo.all(Package.recent(repository, count)) |
| 78 | end | |
| 79 | ||
| 80 | def package_downloads(package) do | |
| 81 | PackageDownload.package(package) | |
| 82 | |> Repo.all() | |
| 83 | 9 | |> Enum.into(%{}) |
| 84 | end | |
| 85 | ||
| 86 | def packages_downloads_with_all_views(packages) do | |
| 87 | PackageDownload.packages_and_all_download_views(packages) | |
| 88 | |> Repo.all() | |
| 89 | 12 | |> Enum.reduce(%{}, fn {id, view, dls}, acc -> |
| 90 | 0 | Map.update(acc, id, %{view => dls}, &Map.put(&1, view, dls)) |
| 91 | end) | |
| 92 | end | |
| 93 | ||
| 94 | def packages_downloads(packages, view) do | |
| 95 | PackageDownload.packages(packages, view) | |
| 96 | |> Repo.all() | |
| 97 | 0 | |> Enum.into(%{}) |
| 98 | end | |
| 99 | ||
| 100 | def top_downloads(repository, view, count) do | |
| 101 | 2 | top = Repo.all(PackageDownload.top(repository, view, count)) |
| 102 | 2 | packages = top |> Enum.map(fn {package, _downloads} -> package end) |> attach_versions() |
| 103 | ||
| 104 | 2 | Enum.zip_with(packages, top, fn package, {_package, downloads} -> |
| 105 | {package, downloads} | |
| 106 | end) | |
| 107 | end | |
| 108 | ||
| 109 | def total_downloads() do | |
| 110 | PackageDownload.total() | |
| 111 | |> Repo.all() | |
| 112 | 2 | |> Enum.into(%{}) |
| 113 | end | |
| 114 | ||
| 115 | 1 | def accessible_user_owned_packages(nil, _) do |
| 116 | [] | |
| 117 | end | |
| 118 | ||
| 119 | def accessible_user_owned_packages(user, for_user) do | |
| 120 | 9 | repositories = Enum.map(Users.all_organizations(for_user), & &1.repository) |
| 121 | 9 | repository_ids = Enum.map(repositories, & &1.id) |
| 122 | ||
| 123 | # Atoms sort before strings | |
| 124 | 9 | sorter = fn repo -> if(repo.id == 1, do: :first, else: repo.name) end |
| 125 | ||
| 126 | 9 | user.owned_packages |
| 127 | 14 | |> Enum.filter(&(&1.repository_id in repository_ids)) |
| 128 | 9 | |> Enum.sort_by(&[sorter.(&1.repository), &1.name]) |
| 129 | end | |
| 130 | ||
| 131 | def downloads_for_last_n_days(package_id, num_of_days) do | |
| 132 | Package.downloads_for_last_n_days(package_id, num_of_days) | |
| 133 | 5 | |> Repo.all() |
| 134 | end | |
| 135 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.RegistryBuilder do | |
| 1 | import Ecto.Query, only: [from: 2] | |
| 2 | require Hexpm.Repo | |
| 3 | require Logger | |
| 4 | alias Hexpm.Repository.{Package, Release, Repository, Requirement} | |
| 5 | alias Hexpm.Repo | |
| 6 | ||
| 7 | def full(repository) do | |
| 8 | 7 | locked_build(fn -> build_full(repository) end, 300_000) |
| 9 | end | |
| 10 | ||
| 11 | # NOTE: Does not rebuild package indexes, use full/1 instead | |
| 12 | def repository(repository) do | |
| 13 | 41 | locked_build(fn -> build_partial(repository) end, 30_000) |
| 14 | end | |
| 15 | ||
| 16 | def package(package) do | |
| 17 | 40 | build_package(package) |
| 18 | end | |
| 19 | ||
| 20 | def package_delete(package) do | |
| 21 | 7 | delete_package(package) |
| 22 | end | |
| 23 | ||
| 24 | defp locked_build(fun, timeout) do | |
| 25 | 48 | start_time = System.monotonic_time(:millisecond) |
| 26 | 48 | lock(fun, start_time, timeout) |
| 27 | end | |
| 28 | ||
| 29 | defp lock(fun, start_time, timeout) do | |
| 30 | 48 | now = System.monotonic_time(:millisecond) |
| 31 | ||
| 32 | 48 | if now > start_time + timeout do |
| 33 | 0 | raise "lock timeout" |
| 34 | end | |
| 35 | ||
| 36 | 48 | {:ok, ran?} = |
| 37 | Repo.transaction( | |
| 38 | 48 | fn -> run_with_lock(fun, now - start_time) end, |
| 39 | timeout: timeout | |
| 40 | ) | |
| 41 | ||
| 42 | 48 | unless ran? do |
| 43 | 0 | Process.sleep(1000) |
| 44 | 0 | lock(fun, start_time, timeout) |
| 45 | end | |
| 46 | end | |
| 47 | ||
| 48 | 48 | if Mix.env() == :test do |
| 49 | defp run_with_lock(fun, time) do | |
| 50 | 48 | if Repo.try_advisory_lock?(:registry) do |
| 51 | 48 | try do |
| 52 | 48 | Logger.warn("REGISTRY_BUILDER aquired_lock (#{time}ms)") |
| 53 | 48 | fun.() |
| 54 | true | |
| 55 | after | |
| 56 | 48 | Repo.advisory_unlock(:registry) |
| 57 | end | |
| 58 | else | |
| 59 | 0 | Logger.warn("REGISTRY_BUILDER failed_aquire_lock (#{time}ms)") |
| 60 | false | |
| 61 | end | |
| 62 | end | |
| 63 | else | |
| 64 | defp run_with_lock(fun, time) do | |
| 65 | if Repo.try_advisory_xact_lock?(:registry) do | |
| 66 | Logger.warn("REGISTRY_BUILDER aquired_lock (#{time}ms)") | |
| 67 | fun.() | |
| 68 | true | |
| 69 | else | |
| 70 | Logger.warn("REGISTRY_BUILDER failed_aquire_lock (#{time}ms)") | |
| 71 | false | |
| 72 | end | |
| 73 | end | |
| 74 | end | |
| 75 | ||
| 76 | defp build_full(repository) do | |
| 77 | 7 | log(:all, fn -> |
| 78 | 7 | {packages, releases} = tuples(repository, nil) |
| 79 | ||
| 80 | 7 | new = build_all(repository, packages, releases) |
| 81 | 7 | upload_files(repository, new) |
| 82 | ||
| 83 | 7 | {_, _, packages} = new |
| 84 | ||
| 85 | 7 | new_keys = |
| 86 | 14 | Enum.map(packages, &repository_store_key(repository, "packages/#{elem(&1, 0)}")) |
| 87 | |> Enum.sort() | |
| 88 | ||
| 89 | 7 | old_keys = |
| 90 | Hexpm.Store.list(:repo_bucket, repository_store_key(repository, "packages/")) | |
| 91 | |> Enum.sort() | |
| 92 | ||
| 93 | 7 | Hexpm.Store.delete_many(:repo_bucket, old_keys -- new_keys) |
| 94 | ||
| 95 | 7 | Hexpm.CDN.purge_key(:fastly_hexrepo, [ |
| 96 | "registry", | |
| 97 | repository_cdn_key(repository, "registry") | |
| 98 | ]) | |
| 99 | end) | |
| 100 | end | |
| 101 | ||
| 102 | defp build_partial(repository) do | |
| 103 | 41 | log(:repository, fn -> |
| 104 | 41 | {packages, releases} = tuples(repository, nil) |
| 105 | 41 | release_map = Map.new(releases) |
| 106 | ||
| 107 | 41 | names = build_names(repository, packages) |
| 108 | 41 | versions = build_versions(repository, packages, release_map) |
| 109 | 41 | upload_files(repository, {names, versions, []}) |
| 110 | ||
| 111 | 41 | Hexpm.CDN.purge_key(:fastly_hexrepo, [ |
| 112 | "registry-index", | |
| 113 | repository_cdn_key(repository, "registry-index") | |
| 114 | ]) | |
| 115 | end) | |
| 116 | end | |
| 117 | ||
| 118 | defp build_package(package) do | |
| 119 | 40 | log(:package_build, fn -> |
| 120 | 40 | repository = package.repository |
| 121 | ||
| 122 | 40 | {packages, releases} = tuples(repository, package) |
| 123 | 40 | release_map = Map.new(releases) |
| 124 | 40 | packages = build_packages(repository, packages, release_map) |
| 125 | ||
| 126 | 40 | upload_files(repository, {nil, nil, packages}) |
| 127 | ||
| 128 | 40 | Hexpm.CDN.purge_key(:fastly_hexrepo, [ |
| 129 | 40 | "registry-package-#{package.name}", |
| 130 | 40 | repository_cdn_key(repository, "registry-package", package.name) |
| 131 | ]) | |
| 132 | end) | |
| 133 | end | |
| 134 | ||
| 135 | defp delete_package(package) do | |
| 136 | 7 | log(:package_delete, fn -> |
| 137 | 7 | repository = package.repository |
| 138 | ||
| 139 | 7 | Hexpm.Store.delete( |
| 140 | :repo_bucket, | |
| 141 | 7 | repository_store_key(repository, "packages/#{package.name}") |
| 142 | ) | |
| 143 | ||
| 144 | 7 | Hexpm.CDN.purge_key(:fastly_hexrepo, [ |
| 145 | 7 | "registry-package-#{package.name}", |
| 146 | 7 | repository_cdn_key(repository, "registry-package", package.name) |
| 147 | ]) | |
| 148 | end) | |
| 149 | end | |
| 150 | ||
| 151 | defp tuples(repository, package) do | |
| 152 | 88 | requirements = requirements(repository, package) |
| 153 | 88 | releases = releases(repository, package) |
| 154 | 88 | packages = packages(repository, package) |
| 155 | 88 | package_tuples = package_tuples(packages, releases) |
| 156 | 88 | release_tuples = release_tuples(packages, releases, requirements) |
| 157 | ||
| 158 | {package_tuples, release_tuples} | |
| 159 | end | |
| 160 | ||
| 161 | defp log(type, fun) do | |
| 162 | 95 | try do |
| 163 | 95 | {time, _} = :timer.tc(fun) |
| 164 | 95 | Logger.warn("REGISTRY_BUILDER completed #{type} (#{div(time, 1000)}ms)") |
| 165 | catch | |
| 166 | exception -> | |
| 167 | 0 | Logger.error("REGISTRY_BUILDER failed #{type}") |
| 168 | 0 | reraise exception, __STACKTRACE__ |
| 169 | end | |
| 170 | end | |
| 171 | ||
| 172 | defp sign_protobuf(contents) do | |
| 173 | 150 | private_key = Application.fetch_env!(:hexpm, :private_key) |
| 174 | 150 | :hex_registry.sign_protobuf(contents, private_key) |
| 175 | end | |
| 176 | ||
| 177 | defp build_all(repository, packages, releases) do | |
| 178 | 7 | release_map = Map.new(releases) |
| 179 | ||
| 180 | 7 | { |
| 181 | build_names(repository, packages), | |
| 182 | build_versions(repository, packages, release_map), | |
| 183 | build_packages(repository, packages, release_map) | |
| 184 | } | |
| 185 | end | |
| 186 | ||
| 187 | defp build_names(repository, packages) do | |
| 188 | 48 | packages = |
| 189 | Enum.map(packages, fn {name, {updated_at, _versions}} -> | |
| 190 | # Currently using Package.updated_at, would be more accurate to use | |
| 191 | # a timestamp that is only updated when the registry is updated by: | |
| 192 | # publish, revert, or retire | |
| 193 | 67 | {seconds, nanos} = to_unix_nano(updated_at) |
| 194 | ||
| 195 | 67 | %{ |
| 196 | name: name, | |
| 197 | updated_at: %{seconds: seconds, nanos: nanos} | |
| 198 | } | |
| 199 | end) | |
| 200 | ||
| 201 | 48 | %{packages: packages, repository: repository.name} |
| 202 | |> :hex_registry.encode_names() | |
| 203 | |> sign_protobuf() | |
| 204 | 48 | |> :zlib.gzip() |
| 205 | end | |
| 206 | ||
| 207 | defp build_versions(repository, packages, release_map) do | |
| 208 | 48 | packages = |
| 209 | Enum.map(packages, fn {name, {_updated_at, [versions]}} -> | |
| 210 | 67 | %{ |
| 211 | name: name, | |
| 212 | versions: versions, | |
| 213 | retired: build_retired_indexes(name, versions, release_map) | |
| 214 | } | |
| 215 | end) | |
| 216 | ||
| 217 | 48 | %{packages: packages, repository: repository.name} |
| 218 | |> :hex_registry.encode_versions() | |
| 219 | |> sign_protobuf() | |
| 220 | 48 | |> :zlib.gzip() |
| 221 | end | |
| 222 | ||
| 223 | defp build_retired_indexes(name, versions, release_map) do | |
| 224 | versions | |
| 225 | |> Enum.with_index() | |
| 226 | 67 | |> Enum.flat_map(fn {version, ix} -> |
| 227 | 79 | [_deps, _inner_checksum, _outer_checksum, _tools, retirement] = release_map[{name, version}] |
| 228 | 79 | if retirement, do: [ix], else: [] |
| 229 | end) | |
| 230 | end | |
| 231 | ||
| 232 | defp build_packages(repository, packages, release_map) do | |
| 233 | 47 | Enum.map(packages, fn {name, {_updated_at, [versions]}} -> |
| 234 | 54 | contents = build_package(repository, name, versions, release_map) |
| 235 | {name, contents} | |
| 236 | end) | |
| 237 | end | |
| 238 | ||
| 239 | defp build_package(repository, name, versions, release_map) do | |
| 240 | 54 | releases = |
| 241 | Enum.map(versions, fn version -> | |
| 242 | 71 | [deps, inner_checksum, outer_checksum, _tools, retirement] = release_map[{name, version}] |
| 243 | ||
| 244 | 71 | deps = |
| 245 | Enum.map(deps, fn [repo, dep, req, opt, app] -> | |
| 246 | 17 | map = %{package: dep, requirement: req || ">= 0.0.0"} |
| 247 | 17 | map = if opt, do: Map.put(map, :optional, true), else: map |
| 248 | 17 | map = if app != dep, do: Map.put(map, :app, app), else: map |
| 249 | 17 | map = if repository.name != repo, do: Map.put(map, :repository, repo), else: map |
| 250 | 17 | map |
| 251 | end) | |
| 252 | ||
| 253 | 71 | release = %{ |
| 254 | version: version, | |
| 255 | inner_checksum: inner_checksum, | |
| 256 | outer_checksum: outer_checksum, | |
| 257 | dependencies: deps | |
| 258 | } | |
| 259 | ||
| 260 | 71 | if retirement do |
| 261 | 6 | retire = %{reason: retirement_reason(retirement.reason)} |
| 262 | ||
| 263 | 6 | retire = |
| 264 | 6 | if retirement.message, do: Map.put(retire, :message, retirement.message), else: retire |
| 265 | ||
| 266 | 6 | Map.put(release, :retired, retire) |
| 267 | else | |
| 268 | 65 | release |
| 269 | end | |
| 270 | end) | |
| 271 | ||
| 272 | %{ | |
| 273 | name: name, | |
| 274 | 54 | repository: repository.name, |
| 275 | releases: releases | |
| 276 | } | |
| 277 | |> :hex_registry.encode_package() | |
| 278 | |> sign_protobuf() | |
| 279 | 54 | |> :zlib.gzip() |
| 280 | end | |
| 281 | ||
| 282 | 0 | defp retirement_reason("other"), do: :RETIRED_OTHER |
| 283 | 0 | defp retirement_reason("invalid"), do: :RETIRED_INVALID |
| 284 | 6 | defp retirement_reason("security"), do: :RETIRED_SECURITY |
| 285 | 0 | defp retirement_reason("deprecated"), do: :RETIRED_DEPRECATED |
| 286 | 0 | defp retirement_reason("renamed"), do: :RETIRED_RENAMED |
| 287 | ||
| 288 | defp upload_files(repository, objects) do | |
| 289 | 88 | upload_objects(objects(objects, repository)) |
| 290 | end | |
| 291 | ||
| 292 | defp upload_objects(objects) do | |
| 293 | Task.async_stream( | |
| 294 | objects, | |
| 295 | fn {key, data, opts} -> | |
| 296 | 150 | Hexpm.Store.put(:repo_bucket, key, data, opts) |
| 297 | end, | |
| 298 | max_concurrency: 10, | |
| 299 | timeout: 60_000 | |
| 300 | ) | |
| 301 | 88 | |> Stream.run() |
| 302 | end | |
| 303 | ||
| 304 | 0 | defp objects(nil, _repository) do |
| 305 | [] | |
| 306 | end | |
| 307 | ||
| 308 | defp objects({nil, nil, packages}, repository) do | |
| 309 | 40 | package_objects(packages, repository) |
| 310 | end | |
| 311 | ||
| 312 | defp objects({names, versions, packages}, repository) do | |
| 313 | 48 | index_objects(names, versions, repository) ++ package_objects(packages, repository) |
| 314 | end | |
| 315 | ||
| 316 | defp index_objects(names, versions, repository) do | |
| 317 | 48 | surrogate_key = |
| 318 | Enum.join( | |
| 319 | [ | |
| 320 | repository_cdn_key(repository, "registry"), | |
| 321 | repository_cdn_key(repository, "registry-index") | |
| 322 | ], | |
| 323 | " " | |
| 324 | ) | |
| 325 | ||
| 326 | 48 | meta = [ |
| 327 | {"surrogate-key", surrogate_key}, | |
| 328 | {"surrogate-control", "public, max-age=604800"} | |
| 329 | ] | |
| 330 | ||
| 331 | 48 | opts = [cache_control: cache_control(repository), meta: meta] |
| 332 | 48 | index_opts = Keyword.put(opts, :meta, meta) |
| 333 | ||
| 334 | 48 | names_object = {repository_store_key(repository, "names"), names, index_opts} |
| 335 | 48 | versions_object = {repository_store_key(repository, "versions"), versions, index_opts} |
| 336 | ||
| 337 | [names_object, versions_object] | |
| 338 | end | |
| 339 | ||
| 340 | defp package_objects(packages, repository) do | |
| 341 | 88 | Enum.map(packages, fn {name, contents} -> |
| 342 | 54 | surrogate_key = |
| 343 | Enum.join( | |
| 344 | [ | |
| 345 | repository_cdn_key(repository, "registry"), | |
| 346 | repository_cdn_key(repository, "registry-package", name) | |
| 347 | ], | |
| 348 | " " | |
| 349 | ) | |
| 350 | ||
| 351 | 54 | meta = [ |
| 352 | {"surrogate-key", surrogate_key}, | |
| 353 | {"surrogate-control", "public, max-age=604800"} | |
| 354 | ] | |
| 355 | ||
| 356 | 54 | opts = [cache_control: cache_control(repository), meta: meta] |
| 357 | 54 | {repository_store_key(repository, "packages/#{name}"), contents, opts} |
| 358 | end) | |
| 359 | end | |
| 360 | ||
| 361 | 77 | defp cache_control(%Repository{id: 1}), do: "public, max-age=3600" |
| 362 | 25 | defp cache_control(%Repository{}), do: "private, max-age=3600" |
| 363 | ||
| 364 | defp package_tuples(packages, releases) do | |
| 365 | Enum.reduce(releases, %{}, fn map, acc -> | |
| 366 | 131 | case Map.fetch(packages, map.package_id) do |
| 367 | {:ok, {package, updated_at}} -> | |
| 368 | 131 | Map.update(acc, package, {updated_at, [map.version]}, fn {^updated_at, versions} -> |
| 369 | 24 | {updated_at, [map.version | versions]} |
| 370 | end) | |
| 371 | ||
| 372 | :error -> | |
| 373 | 0 | acc |
| 374 | end | |
| 375 | end) | |
| 376 | 88 | |> sort_package_tuples() |
| 377 | end | |
| 378 | ||
| 379 | defp sort_package_tuples(tuples) do | |
| 380 | Enum.map(tuples, fn {name, {updated_at, versions}} -> | |
| 381 | 107 | versions = |
| 382 | versions | |
| 383 | 24 | |> Enum.sort(&(Version.compare(&1, &2) == :lt)) |
| 384 | 131 | |> Enum.map(&to_string/1) |
| 385 | ||
| 386 | {name, {updated_at, [versions]}} | |
| 387 | end) | |
| 388 | 88 | |> Enum.sort() |
| 389 | end | |
| 390 | ||
| 391 | defp release_tuples(packages, releases, requirements) do | |
| 392 | 88 | Enum.flat_map(releases, fn map -> |
| 393 | 131 | case Map.fetch(packages, map.package_id) do |
| 394 | {:ok, {package, _updated_at}} -> | |
| 395 | 131 | key = {package, to_string(map.version)} |
| 396 | 131 | deps = deps_list(requirements[map.release_id] || []) |
| 397 | 131 | value = [deps, map.inner_checksum, map.outer_checksum, map.build_tools, map.retirement] |
| 398 | [{key, value}] | |
| 399 | ||
| 400 | 0 | :error -> |
| 401 | [] | |
| 402 | end | |
| 403 | end) | |
| 404 | end | |
| 405 | ||
| 406 | defp deps_list(requirements) do | |
| 407 | 28 | Enum.map(requirements, fn map -> |
| 408 | 28 | [map.repository, map.package, map.requirement, map.optional, map.app] |
| 409 | end) | |
| 410 | 131 | |> Enum.sort() |
| 411 | end | |
| 412 | ||
| 413 | defp packages(repository, nil) do | |
| 414 | from( | |
| 415 | p in Package, | |
| 416 | 48 | where: p.repository_id == ^repository.id, |
| 417 | select: {p.id, {p.name, p.updated_at}} | |
| 418 | ) | |
| 419 | |> Repo.all() | |
| 420 | 48 | |> Map.new() |
| 421 | end | |
| 422 | ||
| 423 | defp packages(_repository, package) do | |
| 424 | 40 | %{package.id => {package.name, package.updated_at}} |
| 425 | end | |
| 426 | ||
| 427 | defp releases(repository, package) do | |
| 428 | from( | |
| 429 | r in Release, | |
| 430 | join: p in assoc(r, :package), | |
| 431 | select: %{ | |
| 432 | release_id: r.id, | |
| 433 | version: r.version, | |
| 434 | package_id: r.package_id, | |
| 435 | inner_checksum: r.inner_checksum, | |
| 436 | outer_checksum: r.outer_checksum, | |
| 437 | build_tools: fragment("?->'build_tools'", r.meta), | |
| 438 | retirement: r.retirement | |
| 439 | } | |
| 440 | ) | |
| 441 | |> releases_where(repository, package) | |
| 442 | 88 | |> Hexpm.Repo.all() |
| 443 | end | |
| 444 | ||
| 445 | defp releases_where(query, repository, nil) do | |
| 446 | 48 | from( |
| 447 | [r, p] in query, | |
| 448 | 48 | where: p.repository_id == ^repository.id |
| 449 | ) | |
| 450 | end | |
| 451 | ||
| 452 | defp releases_where(query, _repository, package) do | |
| 453 | 40 | from( |
| 454 | [r, p] in query, | |
| 455 | 40 | where: p.id == ^package.id |
| 456 | ) | |
| 457 | end | |
| 458 | ||
| 459 | defp requirements(repository, package) do | |
| 460 | 88 | reqs = |
| 461 | from( | |
| 462 | req in Requirement, | |
| 463 | join: rel in assoc(req, :release), | |
| 464 | join: parent in assoc(rel, :package), | |
| 465 | join: dep in assoc(req, :dependency), | |
| 466 | join: dep_repo in assoc(dep, :repository), | |
| 467 | select: %{ | |
| 468 | release_id: req.release_id, | |
| 469 | repository: dep_repo.name, | |
| 470 | package: dep.name, | |
| 471 | app: req.app, | |
| 472 | requirement: req.requirement, | |
| 473 | optional: req.optional | |
| 474 | } | |
| 475 | ) | |
| 476 | |> requirements_where(repository, package) | |
| 477 | |> Repo.all() | |
| 478 | ||
| 479 | 88 | Enum.reduce(reqs, %{}, fn map, acc -> |
| 480 | 28 | {release_id, map} = Map.pop(map, :release_id) |
| 481 | 28 | Map.update(acc, release_id, [map], &[map | &1]) |
| 482 | end) | |
| 483 | end | |
| 484 | ||
| 485 | defp requirements_where(query, repository, nil) do | |
| 486 | 48 | from( |
| 487 | [req, rel, parent] in query, | |
| 488 | 48 | where: parent.repository_id == ^repository.id |
| 489 | ) | |
| 490 | end | |
| 491 | ||
| 492 | defp requirements_where(query, _repository, package) do | |
| 493 | 40 | from( |
| 494 | [req, rel, parent] in query, | |
| 495 | 40 | where: parent.id == ^package.id |
| 496 | ) | |
| 497 | end | |
| 498 | ||
| 499 | defp repository_cdn_key(%Repository{id: 1}, key) do | |
| 500 | 149 | key |
| 501 | end | |
| 502 | ||
| 503 | defp repository_cdn_key(%Repository{name: name}, key) do | |
| 504 | 49 | "#{key}/#{name}" |
| 505 | end | |
| 506 | ||
| 507 | defp repository_cdn_key(%Repository{id: 1}, prefix, suffix) do | |
| 508 | 74 | "#{prefix}/#{suffix}" |
| 509 | end | |
| 510 | ||
| 511 | defp repository_cdn_key(%Repository{name: name}, prefix, suffix) do | |
| 512 | 27 | "#{prefix}/#{name}/#{suffix}" |
| 513 | end | |
| 514 | ||
| 515 | defp repository_store_key(%Repository{id: 1}, key) do | |
| 516 | 137 | key |
| 517 | end | |
| 518 | ||
| 519 | defp repository_store_key(%Repository{name: name}, key) do | |
| 520 | 41 | "repos/#{name}/#{key}" |
| 521 | end | |
| 522 | ||
| 523 | defp to_unix_nano(datetime) do | |
| 524 | 67 | unix = DateTime.to_unix(datetime, :nanosecond) |
| 525 | {div(unix, 1_000_000_000), rem(unix, 1_000_000_000)} | |
| 526 | end | |
| 527 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.Release do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | @derive {HexpmWeb.Stale, assocs: [:requirements, :downloads]} | |
| 4 | @one_hour 60 * 60 | |
| 5 | @one_day @one_hour * 24 | |
| 6 | ||
| 7 | 1986 | schema "releases" do |
| 8 | field :version, Hexpm.Version | |
| 9 | field :inner_checksum, :binary | |
| 10 | field :outer_checksum, :binary | |
| 11 | field :has_docs, :boolean, default: false | |
| 12 | timestamps() | |
| 13 | ||
| 14 | belongs_to :package, Package | |
| 15 | belongs_to(:publisher, User, on_replace: :nilify) | |
| 16 | has_many :requirements, Requirement, on_replace: :delete | |
| 17 | has_many :daily_downloads, Download | |
| 18 | has_many :package_report_releases, PackageReportRelease | |
| 19 | has_many :package_reports, through: [:package_report_releases, :package_report] | |
| 20 | has_one :downloads, ReleaseDownload | |
| 21 | ||
| 22 | embeds_one :meta, ReleaseMetadata, on_replace: :delete | |
| 23 | embeds_one :retirement, ReleaseRetirement, on_replace: :delete | |
| 24 | end | |
| 25 | ||
| 26 | defp changeset( | |
| 27 | release, | |
| 28 | :create, | |
| 29 | params, | |
| 30 | package, | |
| 31 | publisher, | |
| 32 | inner_checksum, | |
| 33 | outer_checksum, | |
| 34 | replace? | |
| 35 | ) do | |
| 36 | changeset( | |
| 37 | release, | |
| 38 | :update, | |
| 39 | params, | |
| 40 | package, | |
| 41 | publisher, | |
| 42 | inner_checksum, | |
| 43 | outer_checksum, | |
| 44 | replace? | |
| 45 | ) | |
| 46 | 76 | |> unique_constraint( |
| 47 | :version, | |
| 48 | name: "releases_package_id_version_key", | |
| 49 | message: "has already been published" | |
| 50 | ) | |
| 51 | end | |
| 52 | ||
| 53 | defp changeset( | |
| 54 | release, | |
| 55 | :update, | |
| 56 | params, | |
| 57 | package, | |
| 58 | publisher, | |
| 59 | inner_checksum, | |
| 60 | outer_checksum, | |
| 61 | replace? | |
| 62 | ) do | |
| 63 | cast(release, params, ~w(version)a) | |
| 64 | |> cast_embed(:meta, required: true) | |
| 65 | |> validate_version(:version) | |
| 66 | |> validate_editable(:update, false, replace?) | |
| 67 | |> put_change(:inner_checksum, inner_checksum) | |
| 68 | |> put_change(:outer_checksum, outer_checksum) | |
| 69 | |> put_assoc(:publisher, publisher) | |
| 70 | 90 | |> Requirement.build_all(package) |
| 71 | end | |
| 72 | ||
| 73 | def build(package, publisher, params, inner_checksum, outer_checksum, replace? \\ true) do | |
| 74 | build_assoc(package, :releases) | |
| 75 | 76 | |> changeset(:create, params, package, publisher, inner_checksum, outer_checksum, replace?) |
| 76 | end | |
| 77 | ||
| 78 | def update(release, publisher, params, inner_checksum, outer_checksum, replace? \\ true) do | |
| 79 | 14 | changeset( |
| 80 | release, | |
| 81 | :update, | |
| 82 | params, | |
| 83 | 14 | release.package, |
| 84 | publisher, | |
| 85 | inner_checksum, | |
| 86 | outer_checksum, | |
| 87 | replace? | |
| 88 | ) | |
| 89 | end | |
| 90 | ||
| 91 | def delete(release, opts \\ []) do | |
| 92 | 12 | force? = Keyword.get(opts, :force, false) |
| 93 | ||
| 94 | change(release) | |
| 95 | 12 | |> validate_editable(:delete, force?, true) |
| 96 | end | |
| 97 | ||
| 98 | def retire(release, params) do | |
| 99 | 3 | cast_embed( |
| 100 | cast(release, params, []), | |
| 101 | :retirement, | |
| 102 | required: true, | |
| 103 | 3 | with: &ReleaseRetirement.changeset(&1, &2, public: true) |
| 104 | ) | |
| 105 | end | |
| 106 | ||
| 107 | def reported_retire(release) do | |
| 108 | change( | |
| 109 | release, | |
| 110 | %{ | |
| 111 | retirement: %{ | |
| 112 | reason: "report", | |
| 113 | message: "security vulnerability reported" | |
| 114 | } | |
| 115 | } | |
| 116 | ) | |
| 117 | 4 | |> cast_embed( |
| 118 | :retirement, | |
| 119 | required: true, | |
| 120 | 0 | with: &ReleaseRetirement.changeset(&1, &2, public: false) |
| 121 | ) | |
| 122 | end | |
| 123 | ||
| 124 | def unretire(release) do | |
| 125 | change(release) | |
| 126 | 3 | |> put_embed(:retirement, nil) |
| 127 | end | |
| 128 | ||
| 129 | defp validate_editable(changeset, _action, true = _force?, _replace?) do | |
| 130 | 0 | changeset |
| 131 | end | |
| 132 | ||
| 133 | defp validate_editable(changeset, action, _force?, replace?) do | |
| 134 | 102 | cond do |
| 135 | 102 | is_nil(changeset.data.inserted_at) -> |
| 136 | 76 | changeset |
| 137 | ||
| 138 | 26 | not editable?(changeset.data) -> |
| 139 | 4 | add_error(changeset, :inserted_at, editable_error_message(action)) |
| 140 | ||
| 141 | 22 | replace? not in [true, "true"] -> |
| 142 | 2 | message = "must include the --replace flag to update an existing release" |
| 143 | 2 | add_error(changeset, :inserted_at, message) |
| 144 | ||
| 145 | 20 | true -> |
| 146 | 20 | changeset |
| 147 | end | |
| 148 | end | |
| 149 | ||
| 150 | 3 | defp editable_error_message(:update) do |
| 151 | "can only modify a release up to one hour after publication" | |
| 152 | end | |
| 153 | ||
| 154 | 1 | defp editable_error_message(:delete), |
| 155 | do: "can only delete a release up to one hour after publication" | |
| 156 | ||
| 157 | defp editable?(release) do | |
| 158 | 26 | release.package.repository.id != 1 or |
| 159 | 26 | within_seconds?(release.inserted_at, @one_hour) or |
| 160 | 5 | within_seconds?(release.package.inserted_at, @one_day) |
| 161 | end | |
| 162 | ||
| 163 | defp within_seconds?(datetime, within_seconds) do | |
| 164 | 26 | at = |
| 165 | datetime | |
| 166 | |> NaiveDateTime.to_erl() | |
| 167 | |> erl_to_seconds() | |
| 168 | ||
| 169 | 26 | now = erl_to_seconds(:calendar.universal_time()) |
| 170 | 26 | now - at <= within_seconds |
| 171 | end | |
| 172 | ||
| 173 | 52 | defp erl_to_seconds(datetime), do: :calendar.datetime_to_gregorian_seconds(datetime) |
| 174 | ||
| 175 | def package_versions(packages) do | |
| 176 | 14 | package_ids = Enum.map(packages, & &1.id) |
| 177 | ||
| 178 | 14 | from( |
| 179 | r in Release, | |
| 180 | where: r.package_id in ^package_ids, | |
| 181 | group_by: r.package_id, | |
| 182 | select: {r.package_id, fragment("array_agg(?)", r.version)} | |
| 183 | ) | |
| 184 | end | |
| 185 | ||
| 186 | 7 | def latest_version(nil, _opts), do: nil |
| 187 | ||
| 188 | def latest_version(releases, opts) do | |
| 189 | 76 | only_stable? = Keyword.fetch!(opts, :only_stable) |
| 190 | 76 | unstable_fallback? = Keyword.get(opts, :unstable_fallback, false) |
| 191 | 76 | with_docs? = Keyword.get(opts, :with_docs) |
| 192 | ||
| 193 | 76 | with_docs_releases = |
| 194 | if with_docs? do | |
| 195 | 10 | Enum.filter(releases, & &1.has_docs) |
| 196 | else | |
| 197 | 66 | releases |
| 198 | end | |
| 199 | ||
| 200 | 76 | stable_releases = |
| 201 | if only_stable? do | |
| 202 | 52 | Enum.filter(with_docs_releases, &(to_version(&1).pre == [])) |
| 203 | else | |
| 204 | 24 | with_docs_releases |
| 205 | end | |
| 206 | ||
| 207 | 76 | if stable_releases == [] and unstable_fallback? do |
| 208 | 2 | latest(releases) |
| 209 | else | |
| 210 | 74 | latest(stable_releases) |
| 211 | end | |
| 212 | end | |
| 213 | ||
| 214 | 0 | defp latest([]), do: nil |
| 215 | ||
| 216 | defp latest(releases) do | |
| 217 | 76 | Enum.reduce(releases, fn release, latest -> |
| 218 | 24 | if compare(release, latest) == :lt do |
| 219 | 16 | latest |
| 220 | else | |
| 221 | 8 | release |
| 222 | end | |
| 223 | end) | |
| 224 | end | |
| 225 | ||
| 226 | defp compare(release1, release2) do | |
| 227 | 24 | Version.compare(to_version(release1), to_version(release2)) |
| 228 | end | |
| 229 | ||
| 230 | 95 | defp to_version(%Release{version: version}), do: to_version(version) |
| 231 | 95 | defp to_version(%Version{} = version), do: version |
| 232 | 38 | defp to_version(version) when is_binary(version), do: Version.parse!(version) |
| 233 | ||
| 234 | def all(package) do | |
| 235 | 15 | assoc(package, :releases) |
| 236 | end | |
| 237 | ||
| 238 | def sort(releases) do | |
| 239 | 97 | Enum.sort(releases, &(Version.compare(&1.version, &2.version) == :gt)) |
| 240 | end | |
| 241 | ||
| 242 | def requirements(release) do | |
| 243 | 28 | from( |
| 244 | req in assoc(release, :requirements), | |
| 245 | join: package in assoc(req, :dependency), | |
| 246 | join: repo in assoc(package, :repository), | |
| 247 | order_by: [repo.name, package.name], | |
| 248 | select: %{req | name: package.name, repository: repo.name} | |
| 249 | ) | |
| 250 | end | |
| 251 | ||
| 252 | def count() do | |
| 253 | 2 | from(r in Release, select: count(r.id)) |
| 254 | end | |
| 255 | ||
| 256 | def recent(repository, count) do | |
| 257 | 2 | from( |
| 258 | r in Hexpm.Repository.Release, | |
| 259 | join: p in assoc(r, :package), | |
| 260 | 2 | where: p.repository_id == ^repository.id, |
| 261 | order_by: [desc: r.inserted_at], | |
| 262 | limit: ^count, | |
| 263 | select: {p.name, r.version, r.inserted_at, p.meta} | |
| 264 | ) | |
| 265 | end | |
| 266 | ||
| 267 | def downloads_for_last_n_days(release_id, num_of_days) do | |
| 268 | 4 | date_start = Date.add(Date.utc_today(), -1 * num_of_days) |
| 269 | 4 | from(d in downloads_by_period(release_id, :day), where: d.day >= ^date_start) |
| 270 | end | |
| 271 | ||
| 272 | def downloads_by_period(release_id, filter) do | |
| 273 | from(d in Download, where: d.release_id == ^release_id) | |
| 274 | 12 | |> Download.query_filter(filter) |
| 275 | end | |
| 276 | end | |
| 277 | ||
| 278 | defimpl Phoenix.Param, for: Hexpm.Repository.Release do | |
| 279 | def to_param(release) do | |
| 280 | 132 | to_string(release.version) |
| 281 | end | |
| 282 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.ReleaseDownload do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | @derive HexpmWeb.Stale | |
| 4 | @primary_key false | |
| 5 | ||
| 6 | 19 | schema "release_downloads" do |
| 7 | belongs_to(:release, Release, references: :id) | |
| 8 | field :downloads, :integer | |
| 9 | end | |
| 10 | ||
| 11 | def release(release) do | |
| 12 | 9 | from(rd in ReleaseDownload, where: rd.release_id == ^release.id) |
| 13 | end | |
| 14 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.ReleaseMetadata do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | @derive HexpmWeb.Stale | |
| 4 | ||
| 5 | 1853 | embedded_schema do |
| 6 | field :app, :string | |
| 7 | field :build_tools, {:array, :string} | |
| 8 | field :elixir, :string | |
| 9 | field :files, {:array, :string}, virtual: true | |
| 10 | end | |
| 11 | ||
| 12 | def changeset(meta, params) do | |
| 13 | cast(meta, params, ~w(app build_tools elixir files)a) | |
| 14 | |> validate_required(~w(app build_tools files)a) | |
| 15 | |> validate_list_required(:build_tools) | |
| 16 | |> validate_list_required(:files, message: "package can't be empty") | |
| 17 | |> update_change(:build_tools, &Enum.uniq/1) | |
| 18 | 87 | |> validate_requirement(:elixir) |
| 19 | end | |
| 20 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.ReleaseRetirement do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | @derive HexpmWeb.Stale | |
| 4 | ||
| 5 | 136 | embedded_schema do |
| 6 | field :reason, :string | |
| 7 | field :message, :string | |
| 8 | end | |
| 9 | ||
| 10 | @public_reasons ~w(other invalid security deprecated renamed) | |
| 11 | @private_reasons @public_reasons ++ ~w(report) | |
| 12 | ||
| 13 | def changeset(meta, params, opts) do | |
| 14 | cast(meta, params, ~w(reason message)a) | |
| 15 | |> validate_required(~w(reason)a) | |
| 16 | |> validate_length(:message, min: 3, max: 140) | |
| 17 | 3 | |> validate_reason(Keyword.fetch!(opts, :public)) |
| 18 | end | |
| 19 | ||
| 20 | defp validate_reason(changeset, true = _public?), | |
| 21 | 3 | do: validate_inclusion(changeset, :reason, @public_reasons) |
| 22 | ||
| 23 | defp validate_reason(changeset, false = _public?), | |
| 24 | 0 | do: validate_inclusion(changeset, :reason, @private_reasons) |
| 25 | ||
| 26 | 4 | def reason_text("other"), do: nil |
| 27 | 0 | def reason_text("invalid"), do: "Release invalid" |
| 28 | 4 | def reason_text("security"), do: "Security issue" |
| 29 | 0 | def reason_text("deprecated"), do: "Deprecated" |
| 30 | 0 | def reason_text("renamed"), do: "Renamed" |
| 31 | 0 | def reason_text("report"), do: "Reported vulnerability" |
| 32 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.Releases do | |
| 1 | use Hexpm.Context | |
| 2 | ||
| 3 | @publish_timeout 60_000 | |
| 4 | ||
| 5 | def all(package) do | |
| 6 | Release.all(package) | |
| 7 | |> Repo.all() | |
| 8 | 11 | |> Release.sort() |
| 9 | end | |
| 10 | ||
| 11 | def recent(repository, count) do | |
| 12 | 2 | Repo.all(Release.recent(repository, count)) |
| 13 | end | |
| 14 | ||
| 15 | def count() do | |
| 16 | 2 | Repo.one!(Release.count()) |
| 17 | end | |
| 18 | ||
| 19 | def get(package, version) do | |
| 20 | 62 | release = Repo.get_by(assoc(package, :releases), version: version) |
| 21 | 62 | release && %{release | package: package} |
| 22 | end | |
| 23 | ||
| 24 | def get(repository, package, version) when is_binary(package) do | |
| 25 | 0 | package = Packages.get(repository, package) |
| 26 | 0 | package && get(package, version) |
| 27 | end | |
| 28 | ||
| 29 | def package_versions(packages) do | |
| 30 | Release.package_versions(packages) | |
| 31 | |> Repo.all() | |
| 32 | 14 | |> Enum.into(%{}) |
| 33 | end | |
| 34 | ||
| 35 | def preload(release, keys) do | |
| 36 | 28 | preload = Enum.map(keys, &preload_field(release, &1)) |
| 37 | 28 | Repo.preload(release, preload) |
| 38 | end | |
| 39 | ||
| 40 | def publish(repository, package, user, body, meta, inner_checksum, outer_checksum, | |
| 41 | audit: audit_data, | |
| 42 | replace: replace? | |
| 43 | ) do | |
| 44 | Multi.new() | |
| 45 | 40 | |> Multi.run(:repository, fn _, _ -> {:ok, repository} end) |
| 46 | 40 | |> Multi.run(:reserved_packages, fn _, _ -> {:ok, reserved_packages(repository, meta)} end) |
| 47 | |> create_package(repository, package, user, meta) | |
| 48 | |> create_release(package, user, inner_checksum, outer_checksum, meta, replace?) | |
| 49 | |> audit_publish(audit_data) | |
| 50 | |> refresh_package_dependants() | |
| 51 | |> Repo.transaction(timeout: @publish_timeout) | |
| 52 | 42 | |> publish_result(user, body) |
| 53 | end | |
| 54 | ||
| 55 | def publish_docs(package, release, body, audit: audit_data) do | |
| 56 | 7 | Assets.push_docs(release, body) |
| 57 | ||
| 58 | 7 | now = DateTime.utc_now() |
| 59 | 7 | release_changeset = Ecto.Changeset.change(release, has_docs: true) |
| 60 | 7 | package_changeset = Ecto.Changeset.change(release.package, docs_updated_at: now) |
| 61 | ||
| 62 | 7 | {:ok, _} = |
| 63 | Multi.new() | |
| 64 | |> Multi.update(:release, release_changeset) | |
| 65 | |> Multi.update(:package, package_changeset) | |
| 66 | |> audit(audit_data, "docs.publish", {package, release}) | |
| 67 | |> Repo.transaction() | |
| 68 | end | |
| 69 | ||
| 70 | def revert(package, release, audit: audit_data) do | |
| 71 | Multi.new() | |
| 72 | |> Multi.delete(:release, Release.delete(release)) | |
| 73 | |> audit_revert(audit_data, package, release) | |
| 74 | |> Multi.run(:release_count, &release_count/2) | |
| 75 | |> Multi.run(:package, &maybe_delete_package/2) | |
| 76 | |> refresh_package_dependants() | |
| 77 | |> Repo.transaction(timeout: @publish_timeout) | |
| 78 | 11 | |> revert_result() |
| 79 | end | |
| 80 | ||
| 81 | def revert_docs(release, audit: audit_data) do | |
| 82 | 2 | now = DateTime.utc_now() |
| 83 | 2 | release_changeset = Ecto.Changeset.change(release, has_docs: false) |
| 84 | 2 | package_changeset = Ecto.Changeset.change(release.package, docs_updated_at: now) |
| 85 | ||
| 86 | 2 | {:ok, _} = |
| 87 | Multi.new() | |
| 88 | |> Multi.update(:release, release_changeset) | |
| 89 | |> Multi.update(:package, package_changeset) | |
| 90 | 2 | |> audit(audit_data, "docs.revert", {release.package, release}) |
| 91 | |> Repo.transaction() | |
| 92 | ||
| 93 | 2 | Assets.revert_docs(release) |
| 94 | end | |
| 95 | ||
| 96 | def retire(package, release, params, audit: audit_data) do | |
| 97 | 3 | params = %{"retirement" => params} |
| 98 | ||
| 99 | Multi.new() | |
| 100 | 3 | |> Multi.run(:repository, fn _, _ -> {:ok, package.repository} end) |
| 101 | 3 | |> Multi.run(:package, fn _, _ -> {:ok, package} end) |
| 102 | |> Multi.update(:release, Release.retire(release, params)) | |
| 103 | |> audit_retire(audit_data, package) | |
| 104 | |> Repo.transaction() | |
| 105 | 3 | |> retire_result() |
| 106 | end | |
| 107 | ||
| 108 | def unretire(package, release, audit: audit_data) do | |
| 109 | Multi.new() | |
| 110 | 3 | |> Multi.run(:repository, fn _, _ -> {:ok, package.repository} end) |
| 111 | 3 | |> Multi.run(:package, fn _, _ -> {:ok, package} end) |
| 112 | |> Multi.update(:release, Release.unretire(release)) | |
| 113 | |> audit_unretire(audit_data, package) | |
| 114 | |> Repo.transaction() | |
| 115 | 3 | |> retire_result() |
| 116 | end | |
| 117 | ||
| 118 | def downloads_by_period(package, filter) do | |
| 119 | 8 | Release.downloads_by_period(package, filter || :all) |
| 120 | 8 | |> Repo.all() |
| 121 | end | |
| 122 | ||
| 123 | def downloads_for_last_n_days(release_id, num_of_days) do | |
| 124 | Release.downloads_for_last_n_days(release_id, num_of_days) | |
| 125 | 4 | |> Repo.all() |
| 126 | end | |
| 127 | ||
| 128 | defp publish_result({:ok, %{package: package, release: release} = result}, user, body) do | |
| 129 | 28 | release = %{release | package: package} |
| 130 | ||
| 131 | 28 | Assets.push_release(release, body) |
| 132 | 28 | update_package_in_registry(package) |
| 133 | 28 | email_package_owners(package, release, user) |
| 134 | ||
| 135 | {:ok, %{result | release: release, package: package}} | |
| 136 | end | |
| 137 | ||
| 138 | 14 | defp publish_result(result, _user, _body), do: result |
| 139 | ||
| 140 | defp retire_result({:ok, %{package: package}}) do | |
| 141 | 6 | RegistryBuilder.package(package) |
| 142 | :ok | |
| 143 | end | |
| 144 | ||
| 145 | 0 | defp retire_result(result), do: result |
| 146 | ||
| 147 | defp revert_result({:ok, %{package: package, release: release, release_count: 0}}) do | |
| 148 | 6 | remove_package_from_registry(package) |
| 149 | 6 | Assets.revert_release(release) |
| 150 | :ok | |
| 151 | end | |
| 152 | ||
| 153 | defp revert_result({:ok, %{package: package, release: release, release_count: _}}) do | |
| 154 | 3 | update_package_in_registry(package) |
| 155 | 3 | Assets.revert_release(release) |
| 156 | :ok | |
| 157 | end | |
| 158 | ||
| 159 | 2 | defp revert_result(result), do: result |
| 160 | ||
| 161 | defp create_package(multi, repository, package, user, meta) do | |
| 162 | 42 | changeset = |
| 163 | if package do | |
| 164 | 24 | params = %{"meta" => meta} |
| 165 | 24 | Package.update(package, params) |
| 166 | else | |
| 167 | 18 | params = %{"name" => meta["name"], "meta" => meta} |
| 168 | 18 | Package.build(repository, user, params) |
| 169 | end | |
| 170 | ||
| 171 | 42 | Multi.insert_or_update(multi, :package, fn %{reserved_packages: reserved_packages} -> |
| 172 | 40 | validate_reserved_package(changeset, reserved_packages) |
| 173 | end) | |
| 174 | end | |
| 175 | ||
| 176 | defp create_release(multi, package, user, inner_checksum, outer_checksum, meta, replace?) do | |
| 177 | 42 | version = meta["version"] |
| 178 | ||
| 179 | # Validate version manually to avoid an Ecto.Query.CastError exception | |
| 180 | # which would return an opaque 400 HTTP status | |
| 181 | 42 | case Version.parse(version) do |
| 182 | {:ok, version} -> | |
| 183 | 40 | params = %{ |
| 184 | "app" => meta["app"], | |
| 185 | "version" => version, | |
| 186 | "requirements" => normalize_requirements(meta["requirements"]), | |
| 187 | "meta" => meta | |
| 188 | } | |
| 189 | ||
| 190 | 40 | release = package && Repo.get_by(assoc(package, :releases), version: version) |
| 191 | ||
| 192 | multi | |
| 193 | |> Multi.insert_or_update(:release, fn changes -> | |
| 194 | 36 | %{package: package, reserved_packages: reserved_packages} = changes |
| 195 | ||
| 196 | 36 | changeset = |
| 197 | if release do | |
| 198 | %{release | package: package} | |
| 199 | |> preload([:requirements, :publisher]) | |
| 200 | 11 | |> Release.update(user, params, inner_checksum, outer_checksum, replace?) |
| 201 | else | |
| 202 | 25 | Release.build(package, user, params, inner_checksum, outer_checksum, replace?) |
| 203 | end | |
| 204 | ||
| 205 | 36 | validate_reserved_version(changeset, reserved_packages) |
| 206 | end) | |
| 207 | 40 | |> Multi.run(:action, fn _, _ -> {:ok, if(release, do: :update, else: :insert)} end) |
| 208 | ||
| 209 | :error -> | |
| 210 | 2 | params = %{version: Hexpm.Version} |
| 211 | 2 | change = Ecto.Changeset.cast({%{}, params}, %{version: version}, ~w(version)a) |
| 212 | 2 | Ecto.Multi.error(multi, :version, change) |
| 213 | end | |
| 214 | end | |
| 215 | ||
| 216 | defp refresh_package_dependants(multi) do | |
| 217 | 53 | Multi.run(multi, :refresh, fn repo, _ -> |
| 218 | 37 | :ok = repo.refresh_view(Hexpm.Repository.PackageDependant) |
| 219 | {:ok, :refresh} | |
| 220 | end) | |
| 221 | end | |
| 222 | ||
| 223 | 10 | defp release_count(repo, %{release: release}) do |
| 224 | 10 | {:ok, repo.aggregate(assoc(release.package, :releases), :count, :id)} |
| 225 | end | |
| 226 | ||
| 227 | defp maybe_delete_package(repo, %{release_count: release_count, release: release}) do | |
| 228 | 10 | if release_count == 0 do |
| 229 | 7 | release.package |
| 230 | |> Package.delete() | |
| 231 | 7 | |> repo.delete() |
| 232 | else | |
| 233 | 3 | {:ok, release.package} |
| 234 | end | |
| 235 | end | |
| 236 | ||
| 237 | defp email_package_owners(package, release, publisher) do | |
| 238 | Hexpm.Repo.all(assoc(package, :owners)) | |
| 239 | |> Hexpm.Repo.preload([:emails, organization: [users: :emails]]) | |
| 240 | 28 | |> Emails.package_published(publisher, package.name, release.version) |
| 241 | 28 | |> Mailer.deliver_later!() |
| 242 | end | |
| 243 | ||
| 244 | if Mix.env() == :test do | |
| 245 | defp update_package_in_registry(package) do | |
| 246 | 31 | RegistryBuilder.package(package) |
| 247 | 31 | RegistryBuilder.repository(package.repository) |
| 248 | end | |
| 249 | ||
| 250 | defp remove_package_from_registry(package) do | |
| 251 | 6 | RegistryBuilder.package_delete(package) |
| 252 | 6 | RegistryBuilder.repository(package.repository) |
| 253 | end | |
| 254 | else | |
| 255 | defp update_package_in_registry(package) do | |
| 256 | RegistryBuilder.package(package) | |
| 257 | metadata = Logger.metadata() | |
| 258 | ||
| 259 | Task.Supervisor.start_child(Hexpm.Tasks, fn -> | |
| 260 | Logger.metadata(metadata) | |
| 261 | RegistryBuilder.repository(package.repository) | |
| 262 | end) | |
| 263 | end | |
| 264 | ||
| 265 | defp remove_package_from_registry(package) do | |
| 266 | RegistryBuilder.package_delete(package) | |
| 267 | metadata = Logger.metadata() | |
| 268 | ||
| 269 | Task.Supervisor.start_child(Hexpm.Tasks, fn -> | |
| 270 | Logger.metadata(metadata) | |
| 271 | RegistryBuilder.repository(package.repository) | |
| 272 | end) | |
| 273 | end | |
| 274 | end | |
| 275 | ||
| 276 | defp reserved_packages(repository, %{"name" => name}) when is_binary(name) do | |
| 277 | from( | |
| 278 | r in "reserved_packages", | |
| 279 | 40 | where: r.repository_id == ^repository.id, |
| 280 | where: r.name == ^name, | |
| 281 | select: r.version | |
| 282 | ) | |
| 283 | |> Repo.all() | |
| 284 | 40 | |> Enum.map(fn version -> |
| 285 | 2 | if version do |
| 286 | 1 | {:ok, version} = Version.parse(version) |
| 287 | 1 | version |
| 288 | end | |
| 289 | end) | |
| 290 | end | |
| 291 | ||
| 292 | 0 | defp reserved_packages(_repository, _meta) do |
| 293 | [] | |
| 294 | end | |
| 295 | ||
| 296 | defp validate_reserved_package(changeset, reserved) do | |
| 297 | 40 | if nil in reserved do |
| 298 | 1 | validate_exclusion(changeset, :name, [get_field(changeset, :name)]) |
| 299 | else | |
| 300 | 39 | changeset |
| 301 | end | |
| 302 | end | |
| 303 | ||
| 304 | defp validate_reserved_version(changeset, reserved) do | |
| 305 | 36 | validate_exclusion(changeset, :version, reserved) |
| 306 | end | |
| 307 | ||
| 308 | defp audit_publish(multi, audit_data) do | |
| 309 | 42 | audit(multi, audit_data, "release.publish", fn %{package: pkg, release: rel} -> {pkg, rel} end) |
| 310 | end | |
| 311 | ||
| 312 | defp audit_revert(multi, audit_data, package, release) do | |
| 313 | 11 | audit(multi, audit_data, "release.revert", {package, release}) |
| 314 | end | |
| 315 | ||
| 316 | defp audit_retire(multi, audit_data, package) do | |
| 317 | 3 | audit(multi, audit_data, "release.retire", fn %{release: rel} -> {package, rel} end) |
| 318 | end | |
| 319 | ||
| 320 | defp audit_unretire(multi, audit_data, package) do | |
| 321 | 3 | audit(multi, audit_data, "release.unretire", fn %{release: rel} -> {package, rel} end) |
| 322 | end | |
| 323 | ||
| 324 | defp normalize_requirements(requirements) when is_map(requirements) do | |
| 325 | 35 | Enum.map(requirements, fn |
| 326 | {name, map} when is_map(map) -> | |
| 327 | 5 | Map.put(map, "name", name) |
| 328 | ||
| 329 | other -> | |
| 330 | 0 | other |
| 331 | end) | |
| 332 | end | |
| 333 | ||
| 334 | 5 | defp normalize_requirements(requirements), do: requirements |
| 335 | ||
| 336 | 28 | defp preload_field(release, :requirements), do: {:requirements, Release.requirements(release)} |
| 337 | 9 | defp preload_field(release, :downloads), do: {:downloads, ReleaseDownload.release(release)} |
| 338 | 28 | defp preload_field(_release, :publisher), do: {:publisher, [:emails, :organization]} |
| 339 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.Repositories do | |
| 1 | use Hexpm.Context | |
| 2 | ||
| 3 | 2 | def all_public() do |
| 4 | [Repository.hexpm()] | |
| 5 | end | |
| 6 | ||
| 7 | def get(name, preload \\ []) do | |
| 8 | Repo.get_by(Repository, name: name) | |
| 9 | 184 | |> Repo.preload(preload) |
| 10 | end | |
| 11 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.Repository do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | @derive HexpmWeb.Stale | |
| 4 | @derive {Phoenix.Param, key: :name} | |
| 5 | ||
| 6 | 1277 | schema "repositories" do |
| 7 | field :name, :string | |
| 8 | timestamps() | |
| 9 | ||
| 10 | belongs_to :organization, Organization | |
| 11 | has_many :packages, Package | |
| 12 | end | |
| 13 | ||
| 14 | def hexpm(opts \\ []) do | |
| 15 | 81 | organization = |
| 16 | if Keyword.get(opts, :recursive, true) do | |
| 17 | 43 | Organization.hexpm(recursive: false) |
| 18 | else | |
| 19 | 38 | %Ecto.Association.NotLoaded{} |
| 20 | end | |
| 21 | ||
| 22 | 81 | %__MODULE__{ |
| 23 | id: 1, | |
| 24 | name: "hexpm", | |
| 25 | organization: organization, | |
| 26 | organization_id: 1 | |
| 27 | } | |
| 28 | end | |
| 29 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.Requirement do | |
| 1 | use Hexpm.Schema | |
| 2 | require Logger | |
| 3 | ||
| 4 | @derive {HexpmWeb.Stale, last_modified: nil} | |
| 5 | ||
| 6 | 610 | schema "requirements" do |
| 7 | field :app, :string | |
| 8 | field :requirement, :string | |
| 9 | field :optional, :boolean, default: false | |
| 10 | ||
| 11 | # The repository and name of the dependency used to find the package | |
| 12 | field :repository, :string, virtual: true | |
| 13 | field :name, :string, virtual: true | |
| 14 | ||
| 15 | belongs_to :release, Release | |
| 16 | belongs_to :dependency, Package | |
| 17 | end | |
| 18 | ||
| 19 | def changeset(requirement, params, dependencies, package) do | |
| 20 | 23 | repository = params["repository"] || "hexpm" |
| 21 | ||
| 22 | cast(requirement, params, ~w(repository name app requirement optional)a) | |
| 23 | |> put_assoc(:dependency, dependencies[{repository, params["name"]}]) | |
| 24 | |> validate_required(~w(name app requirement optional)a) | |
| 25 | |> validate_required( | |
| 26 | :dependency, | |
| 27 | 23 | message: "package does not exist in repository \"#{repository}\"" |
| 28 | ) | |
| 29 | |> validate_requirement(:requirement) | |
| 30 | 23 | |> validate_repository(:repository, repository: package.repository) |
| 31 | end | |
| 32 | ||
| 33 | def build_all(release_changeset, package) do | |
| 34 | 90 | dependencies = preload_dependencies(release_changeset.params["requirements"]) |
| 35 | ||
| 36 | 90 | release_changeset = |
| 37 | cast_assoc( | |
| 38 | release_changeset, | |
| 39 | :requirements, | |
| 40 | 23 | with: &changeset(&1, &2, dependencies, package) |
| 41 | ) | |
| 42 | ||
| 43 | 90 | if release_changeset.valid? do |
| 44 | 69 | requirements = |
| 45 | get_change(release_changeset, :requirements, []) | |
| 46 | |> Enum.map(&apply_changes/1) | |
| 47 | ||
| 48 | 69 | validate_resolver(release_changeset, requirements) |
| 49 | else | |
| 50 | 21 | release_changeset |
| 51 | end | |
| 52 | end | |
| 53 | ||
| 54 | defp validate_resolver(release_changeset, _requirements) do | |
| 55 | 69 | release_changeset |
| 56 | end | |
| 57 | ||
| 58 | # Disabled because of bug | |
| 59 | # defp validate_resolver(%{valid?: true} = release_changeset, requirements) do | |
| 60 | # build_tools = get_field(release_changeset, :meta).build_tools | |
| 61 | # | |
| 62 | # {time, release_changeset} = | |
| 63 | # :timer.tc(fn -> | |
| 64 | # case Resolver.run(requirements, build_tools) do | |
| 65 | # :ok -> | |
| 66 | # release_changeset | |
| 67 | # | |
| 68 | # {:error, reason} -> | |
| 69 | # add_error(release_changeset, :requirements, reason) | |
| 70 | # end | |
| 71 | # end) | |
| 72 | # | |
| 73 | # Logger.warn("DEPENDENCY_RESOLUTION_COMPLETED (#{div(time, 1000)}ms)") | |
| 74 | # release_changeset | |
| 75 | # end | |
| 76 | # | |
| 77 | # defp validate_resolver(%{valid?: false} = release_changeset, _requirements) do | |
| 78 | # release_changeset | |
| 79 | # end | |
| 80 | ||
| 81 | defp preload_dependencies(requirements) do | |
| 82 | 90 | names = requirement_names(requirements) |
| 83 | ||
| 84 | from( | |
| 85 | p in Package, | |
| 86 | join: r in assoc(p, :repository), | |
| 87 | select: {{r.name, p.name}, %{p | repository: r}} | |
| 88 | ) | |
| 89 | 90 | |> filter_dependencies(names) |
| 90 | end | |
| 91 | ||
| 92 | defp filter_dependencies(_query, []) do | |
| 93 | 70 | %{} |
| 94 | end | |
| 95 | ||
| 96 | defp filter_dependencies(query, names) do | |
| 97 | import Ecto.Query, only: [or_where: 3] | |
| 98 | ||
| 99 | Enum.reduce(names, query, fn {repository, package}, query -> | |
| 100 | 23 | or_where(query, [p, r], r.name == ^repository and p.name == ^package) |
| 101 | end) | |
| 102 | |> Hexpm.Repo.all() | |
| 103 | 20 | |> Map.new() |
| 104 | end | |
| 105 | ||
| 106 | defp requirement_names(requirements) when is_list(requirements) do | |
| 107 | 83 | Enum.flat_map(requirements, fn |
| 108 | req when is_map(req) -> | |
| 109 | 23 | name = req["name"] |
| 110 | 23 | repository = req["repository"] || "hexpm" |
| 111 | ||
| 112 | 23 | if is_binary(name) and is_binary(repository) do |
| 113 | [{repository, name}] | |
| 114 | else | |
| 115 | [] | |
| 116 | end | |
| 117 | ||
| 118 | 0 | _ -> |
| 119 | [] | |
| 120 | end) | |
| 121 | end | |
| 122 | ||
| 123 | 7 | defp requirement_names(_requirements), do: [] |
| 124 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.Resolver do | |
| 1 | import Ecto.Query, only: [from: 2, or_where: 3] | |
| 2 | ||
| 3 | @behaviour Hex.Registry | |
| 4 | ||
| 5 | def run(requirements, build_tools) do | |
| 6 | 0 | config = guess_config(build_tools) |
| 7 | 0 | resolve(requirements, config) |
| 8 | end | |
| 9 | ||
| 10 | 0 | defp resolve(requirements, config) do |
| 11 | 0 | {:ok, _name} = open() |
| 12 | ||
| 13 | 0 | deps = resolve_deps(requirements) |
| 14 | 0 | top_level = Enum.map(deps, &elem(&1, 0)) |
| 15 | 0 | requests = resolve_new_requests(requirements, config) |
| 16 | ||
| 17 | requests | |
| 18 | 0 | |> Enum.map(&{elem(&1, 0), elem(&1, 1)}) |
| 19 | 0 | |> prefetch() |
| 20 | ||
| 21 | Hex.Resolver.resolve(__MODULE__, requests, deps, top_level, %{}, []) | |
| 22 | 0 | |> resolve_result() |
| 23 | after | |
| 24 | 0 | close() |
| 25 | end | |
| 26 | ||
| 27 | 0 | defp resolve_result({:ok, _}), do: :ok |
| 28 | 0 | defp resolve_result({:error, {:version, messages}}), do: {:error, remove_ansi_escapes(messages)} |
| 29 | 0 | defp resolve_result({:error, {:repo, messages}}), do: {:error, remove_ansi_escapes(messages)} |
| 30 | 0 | defp resolve_result({:error, messages}), do: {:error, remove_ansi_escapes(messages)} |
| 31 | ||
| 32 | defp remove_ansi_escapes(string) do | |
| 33 | 0 | String.replace(string, ~r"\e\[[0-9]+[a-zA-Z]", "") |
| 34 | end | |
| 35 | ||
| 36 | defp resolve_deps(requirements) do | |
| 37 | 0 | if Version.compare(Hex.version(), "0.18.0-dev") in [:eq, :gt] do |
| 38 | 0 | Map.new(requirements, fn %{app: app} -> |
| 39 | {app, {false, %{}}} | |
| 40 | end) | |
| 41 | else | |
| 42 | 0 | Enum.map(requirements, fn %{repository: repository, app: app} -> |
| 43 | 0 | {repository || "hexpm", app, false, []} |
| 44 | end) | |
| 45 | end | |
| 46 | end | |
| 47 | ||
| 48 | defp resolve_new_requests(requirements, config) do | |
| 49 | 0 | Enum.map(requirements, fn %{repository: repository, name: name, app: app, requirement: req} -> |
| 50 | 0 | {repository || "hexpm", name, app, req, config} |
| 51 | end) | |
| 52 | end | |
| 53 | ||
| 54 | defp guess_config(build_tools) when is_list(build_tools) do | |
| 55 | 0 | cond do |
| 56 | 0 | "mix" in build_tools -> "mix.exs" |
| 57 | 0 | "rebar" in build_tools -> "rebar.config" |
| 58 | 0 | "rebar3" in build_tools -> "rebar.config" |
| 59 | 0 | "erlang.mk" in build_tools -> "Makefile" |
| 60 | 0 | true -> "TOP CONFIG" |
| 61 | end | |
| 62 | end | |
| 63 | ||
| 64 | 0 | defp guess_config(_), do: "TOP CONFIG" |
| 65 | ||
| 66 | ### Hex.Registry callbacks ### | |
| 67 | ||
| 68 | def open(_opts \\ []) do | |
| 69 | 0 | tid = :ets.new(__MODULE__, []) |
| 70 | 0 | Process.put(__MODULE__, tid) |
| 71 | {:ok, tid} | |
| 72 | end | |
| 73 | ||
| 74 | def close(name \\ Process.get(__MODULE__)) do | |
| 75 | 0 | Process.delete(__MODULE__) |
| 76 | ||
| 77 | 0 | if :ets.info(name) == :undefined do |
| 78 | false | |
| 79 | else | |
| 80 | 0 | :ets.delete(name) |
| 81 | end | |
| 82 | end | |
| 83 | ||
| 84 | def versions(name \\ Process.get(__MODULE__), repository, package) do | |
| 85 | 0 | :ets.lookup_element(name, {:versions, repository, package}, 2) |
| 86 | end | |
| 87 | ||
| 88 | def deps(name \\ Process.get(__MODULE__), repository, package, version) do | |
| 89 | 0 | case :ets.lookup(name, {:deps, repository, package, version}) do |
| 90 | [{_, deps}] -> | |
| 91 | 0 | deps |
| 92 | ||
| 93 | [] -> | |
| 94 | 0 | release_id = :ets.lookup_element(name, {:release, repository, package, version}, 2) |
| 95 | ||
| 96 | 0 | deps = |
| 97 | from( | |
| 98 | r in Hexpm.Repository.Requirement, | |
| 99 | join: p in assoc(r, :dependency), | |
| 100 | join: repo in assoc(p, :repository), | |
| 101 | where: r.release_id == ^release_id, | |
| 102 | select: {repo.name, p.name, r.app, r.requirement, r.optional} | |
| 103 | ) | |
| 104 | |> Hexpm.Repo.all() | |
| 105 | ||
| 106 | 0 | :ets.insert(name, {{:deps, repository, package, version}, deps}) |
| 107 | 0 | deps |
| 108 | end | |
| 109 | end | |
| 110 | ||
| 111 | def prefetch(name \\ Process.get(__MODULE__), packages) do | |
| 112 | 0 | packages = |
| 113 | packages | |
| 114 | |> Enum.uniq() | |
| 115 | 0 | |> Enum.reject(fn {repo, package} -> :ets.member(name, {:versions, repo, package}) end) |
| 116 | ||
| 117 | 0 | load_prefetch(name, packages) |
| 118 | end | |
| 119 | ||
| 120 | 0 | defp load_prefetch(_name, []), do: :ok |
| 121 | ||
| 122 | defp load_prefetch(name, packages) do | |
| 123 | 0 | packages_query = |
| 124 | from( | |
| 125 | p in Hexpm.Repository.Package, | |
| 126 | join: r in assoc(p, :repository), | |
| 127 | select: {p.id, {r.name, p.name}} | |
| 128 | ) | |
| 129 | ||
| 130 | 0 | packages = |
| 131 | Enum.reduce(packages, packages_query, fn {repository, package}, query -> | |
| 132 | 0 | or_where(query, [p, r], r.name == ^repository and p.name == ^package) |
| 133 | end) | |
| 134 | |> Hexpm.Repo.all() | |
| 135 | |> Map.new() | |
| 136 | ||
| 137 | 0 | releases = |
| 138 | from( | |
| 139 | r in Hexpm.Repository.Release, | |
| 140 | where: r.package_id in ^Map.keys(packages), | |
| 141 | select: {r.package_id, {r.id, r.version}} | |
| 142 | ) | |
| 143 | |> Hexpm.Repo.all() | |
| 144 | 0 | |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) |
| 145 | ||
| 146 | 0 | versions = |
| 147 | Enum.map(packages, fn {id, {repo, package}} -> | |
| 148 | 0 | versions = |
| 149 | releases[id] | |
| 150 | 0 | |> Enum.map(&elem(&1, 1)) |
| 151 | 0 | |> Enum.sort(&(Version.compare(&1, &2) != :gt)) |
| 152 | ||
| 153 | {{:versions, repo, package}, versions} | |
| 154 | end) | |
| 155 | ||
| 156 | 0 | releases = |
| 157 | Enum.flat_map(releases, fn {pid, versions} -> | |
| 158 | 0 | Enum.map(versions, fn {rid, vsn} -> |
| 159 | 0 | {repo, package} = packages[pid] |
| 160 | {{:release, repo, package, vsn}, rid} | |
| 161 | end) | |
| 162 | end) | |
| 163 | ||
| 164 | 0 | :ets.insert(name, versions ++ releases) |
| 165 | end | |
| 166 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Repository.Sitemaps do | |
| 1 | use Hexpm.Context | |
| 2 | ||
| 3 | def packages() do | |
| 4 | from( | |
| 5 | p in Package, | |
| 6 | where: p.repository_id == 1, | |
| 7 | order_by: p.name, | |
| 8 | select: {p.name, p.updated_at} | |
| 9 | ) | |
| 10 | 1 | |> Repo.all() |
| 11 | end | |
| 12 | ||
| 13 | def packages_with_docs() do | |
| 14 | from( | |
| 15 | p in Package, | |
| 16 | join: r in assoc(p, :releases), | |
| 17 | order_by: p.name, | |
| 18 | where: p.repository_id == 1, | |
| 19 | where: not is_nil(p.docs_updated_at), | |
| 20 | where: r.has_docs, | |
| 21 | select: {p.name, p.docs_updated_at}, | |
| 22 | distinct: true | |
| 23 | ) | |
| 24 | 1 | |> Repo.all() |
| 25 | end | |
| 26 | ||
| 27 | def packages_for_preview() do | |
| 28 | 1 | releases_query = from(Release, select: [:version, :retirement]) |
| 29 | ||
| 30 | 1 | query = |
| 31 | from(Package, | |
| 32 | order_by: :name, | |
| 33 | where: [repository_id: 1], | |
| 34 | select: [:id, :name, :updated_at], | |
| 35 | preload: [releases: ^releases_query] | |
| 36 | ) | |
| 37 | ||
| 38 | 1 | for package <- Repo.all(query) do |
| 39 | 1 | version = Release.latest_version(package.releases, only_stable: false).version |
| 40 | 1 | {package.name, version, package.updated_at} |
| 41 | end | |
| 42 | end | |
| 43 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Schema do | |
| 1 | defmacro __using__(_opts) do | |
| 2 | quote do | |
| 3 | use Ecto.Schema | |
| 4 | @timestamps_opts [type: :utc_datetime_usec] | |
| 5 | ||
| 6 | import Ecto | |
| 7 | import Ecto.Changeset | |
| 8 | import Ecto.Query, only: [from: 1, from: 2] | |
| 9 | import Hexpm.Changeset | |
| 10 | ||
| 11 | alias Ecto.Multi | |
| 12 | ||
| 13 | use Hexpm.Shared | |
| 14 | end | |
| 15 | end | |
| 16 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Shared do | |
| 1 | defmacro __using__(_opts) do | |
| 2 | quote do | |
| 3 | alias Hexpm.{ | |
| 4 | Accounts.AuditLog, | |
| 5 | Accounts.AuditLogs, | |
| 6 | Accounts.Auth, | |
| 7 | Accounts.Email, | |
| 8 | Accounts.Key, | |
| 9 | Accounts.KeyPermission, | |
| 10 | Accounts.Keys, | |
| 11 | Accounts.Organization, | |
| 12 | Accounts.Organizations, | |
| 13 | Accounts.OrganizationUser, | |
| 14 | Accounts.PasswordReset, | |
| 15 | Accounts.Session, | |
| 16 | Accounts.User, | |
| 17 | Accounts.UserHandles, | |
| 18 | Accounts.Users, | |
| 19 | Emails, | |
| 20 | Emails.Mailer, | |
| 21 | Repository.Assets, | |
| 22 | Repository.Download, | |
| 23 | Repository.Install, | |
| 24 | Repository.Installs, | |
| 25 | Repository.Owners, | |
| 26 | Repository.Package, | |
| 27 | Repository.PackageDownload, | |
| 28 | Repository.PackageMetadata, | |
| 29 | Repository.PackageOwner, | |
| 30 | Repository.PackageReport, | |
| 31 | Repository.PackageReportComment, | |
| 32 | Repository.PackageReportRelease, | |
| 33 | Repository.PackageReports, | |
| 34 | Repository.Packages, | |
| 35 | Repository.RegistryBuilder, | |
| 36 | Repository.Release, | |
| 37 | Repository.ReleaseDownload, | |
| 38 | Repository.ReleaseMetadata, | |
| 39 | Repository.ReleaseRetirement, | |
| 40 | Repository.Releases, | |
| 41 | Repository.Repositories, | |
| 42 | Repository.Repository, | |
| 43 | Repository.Requirement, | |
| 44 | Repository.Resolver, | |
| 45 | Repository.Sitemaps | |
| 46 | } | |
| 47 | end | |
| 48 | end | |
| 49 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.ShortURLs.ShortURL do | |
| 1 | use Hexpm.Schema | |
| 2 | ||
| 3 | alias Hexpm.ShortURLs.ShortURL | |
| 4 | alias Hexpm.Repo | |
| 5 | ||
| 6 | 13 | schema "short_urls" do |
| 7 | field :url, :string | |
| 8 | field :short_code, :string | |
| 9 | ||
| 10 | timestamps(updated_at: false) | |
| 11 | end | |
| 12 | ||
| 13 | def changeset(params) do | |
| 14 | %ShortURL{} | |
| 15 | |> cast(params, [:url]) | |
| 16 | |> validate_required([:url]) | |
| 17 | |> ensure_url_domain() | |
| 18 | |> put_change(:short_code, generate_random(5)) | |
| 19 | |> validate_required(:short_code, message: "could not generate a unique short code") | |
| 20 | 8 | |> unique_constraint(:short_code) |
| 21 | end | |
| 22 | ||
| 23 | defp charset do | |
| 24 | 40 | capitals = Enum.map(?A..?Z, fn ch -> <<ch>> end) |
| 25 | 40 | lowers = Enum.map(?a..?z, fn ch -> <<ch>> end) |
| 26 | 40 | numbers = Enum.map(?0..?9, fn ch -> <<ch>> end) |
| 27 | 40 | ambiguous = ["I", "0", "O", "l"] |
| 28 | 40 | (capitals ++ lowers ++ numbers) -- ambiguous |
| 29 | end | |
| 30 | ||
| 31 | 8 | defp generate_random(length, retries \\ 5) |
| 32 | 0 | defp generate_random(_length, 0), do: nil |
| 33 | ||
| 34 | defp generate_random(length, retries) do | |
| 35 | 8 | short_code = IO.iodata_to_binary(Enum.map(1..length, fn _ -> Enum.random(charset()) end)) |
| 36 | # Make sure this short_code is unique before continuing | |
| 37 | 8 | if short_code_unique?(short_code), do: short_code, else: generate_random(length, retries - 1) |
| 38 | end | |
| 39 | ||
| 40 | defp short_code_unique?(short_code) do | |
| 41 | 8 | Repo.get_by(ShortURL, short_code: short_code) |> is_nil() |
| 42 | end | |
| 43 | ||
| 44 | defp ensure_url_domain(changeset) do | |
| 45 | 8 | validate_change(changeset, :url, fn :url, url -> hexpm_url?(url) end) |
| 46 | end | |
| 47 | ||
| 48 | 0 | defp hexpm_url?(nil), do: [] |
| 49 | ||
| 50 | defp hexpm_url?(url) do | |
| 51 | 7 | if URI.parse(url).host =~ ~r/^[\w\.]*hex.pm$/ do |
| 52 | [] | |
| 53 | else | |
| 54 | [url: "domain must match hex.pm or *.hex.pm"] | |
| 55 | end | |
| 56 | end | |
| 57 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.ShortURLs do | |
| 1 | use Hexpm.Context | |
| 2 | alias Hexpm.ShortURLs.ShortURL | |
| 3 | ||
| 4 | def add(params) do | |
| 5 | params | |
| 6 | |> ShortURL.changeset() | |
| 7 | 3 | |> Repo.insert() |
| 8 | end | |
| 9 | ||
| 10 | def get(short_code) do | |
| 11 | 4 | Repo.get_by(ShortURL, short_code: short_code) |
| 12 | end | |
| 13 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Store.GCS do | |
| 1 | import SweetXml, only: [sigil_x: 2] | |
| 2 | require Logger | |
| 3 | ||
| 4 | @behaviour Hexpm.Store | |
| 5 | ||
| 6 | @gs_xml_url "https://storage.googleapis.com" | |
| 7 | ||
| 8 | def list(bucket, prefix) do | |
| 9 | 0 | list_stream(bucket, prefix) |
| 10 | end | |
| 11 | ||
| 12 | def get(bucket, key, _opts) do | |
| 13 | 0 | url = url(bucket, key) |
| 14 | ||
| 15 | 0 | case Hexpm.HTTP.retry(fn -> Hexpm.HTTP.get(url, headers()) end, "gcs") do |
| 16 | 0 | {:ok, 200, _headers, body} -> body |
| 17 | 0 | _ -> nil |
| 18 | end | |
| 19 | end | |
| 20 | ||
| 21 | def put(bucket, key, blob, opts) do | |
| 22 | 0 | headers = |
| 23 | headers() ++ | |
| 24 | meta_headers(Keyword.fetch!(opts, :meta)) ++ | |
| 25 | [ | |
| 26 | {"cache-control", Keyword.fetch!(opts, :cache_control)}, | |
| 27 | {"content-type", Keyword.get(opts, :content_type)} | |
| 28 | ] | |
| 29 | ||
| 30 | 0 | url = url(bucket, key) |
| 31 | 0 | headers = filter_nil_values(headers) |
| 32 | ||
| 33 | 0 | {:ok, 200, _headers, _body} = |
| 34 | 0 | Hexpm.HTTP.retry(fn -> Hexpm.HTTP.put(url, headers, blob) end, "gcs") |
| 35 | ||
| 36 | :ok | |
| 37 | end | |
| 38 | ||
| 39 | def delete_many(bucket, keys) do | |
| 40 | keys | |
| 41 | |> Task.async_stream( | |
| 42 | 0 | &delete(bucket, &1), |
| 43 | max_concurrency: 10, | |
| 44 | timeout: 10_000 | |
| 45 | ) | |
| 46 | 0 | |> Stream.run() |
| 47 | end | |
| 48 | ||
| 49 | def delete(bucket, key) do | |
| 50 | 0 | url = url(bucket, key) |
| 51 | ||
| 52 | 0 | {:ok, 204, _headers, _body} = |
| 53 | 0 | Hexpm.HTTP.retry(fn -> Hexpm.HTTP.delete(url, headers()) end, "gcs") |
| 54 | ||
| 55 | :ok | |
| 56 | end | |
| 57 | ||
| 58 | defp list_stream(bucket, prefix) do | |
| 59 | 0 | start_fun = fn -> nil end |
| 60 | 0 | after_fun = fn _ -> nil end |
| 61 | ||
| 62 | 0 | next_fun = fn |
| 63 | 0 | :halt -> |
| 64 | {:halt, nil} | |
| 65 | ||
| 66 | marker -> | |
| 67 | 0 | {items, marker} = do_list(bucket, prefix, marker) |
| 68 | 0 | {items, marker || :halt} |
| 69 | end | |
| 70 | ||
| 71 | 0 | Stream.resource(start_fun, next_fun, after_fun) |
| 72 | end | |
| 73 | ||
| 74 | defp do_list(bucket, prefix, marker) do | |
| 75 | 0 | url = url(bucket) <> "?prefix=#{prefix}&marker=#{marker}" |
| 76 | ||
| 77 | 0 | {:ok, 200, _headers, body} = Hexpm.HTTP.retry(fn -> Hexpm.HTTP.get(url, headers()) end, "gcs") |
| 78 | ||
| 79 | 0 | doc = SweetXml.parse(body) |
| 80 | 0 | marker = SweetXml.xpath(doc, ~x"/ListBucketResult/NextMarker/text()"s) |
| 81 | 0 | items = SweetXml.xpath(doc, ~x"/ListBucketResult/Contents/Key/text()"ls) |
| 82 | 0 | marker = if marker != "", do: marker |
| 83 | ||
| 84 | {items, marker} | |
| 85 | end | |
| 86 | ||
| 87 | defp filter_nil_values(keyword) do | |
| 88 | 0 | Enum.reject(keyword, fn {_key, value} -> is_nil(value) end) |
| 89 | end | |
| 90 | ||
| 91 | defp headers() do | |
| 92 | 0 | {:ok, token} = Goth.fetch(Hexpm.Goth) |
| 93 | 0 | [{"authorization", "#{token.type} #{token.token}"}] |
| 94 | end | |
| 95 | ||
| 96 | defp meta_headers(meta) do | |
| 97 | 0 | Enum.map(meta, fn {key, value} -> |
| 98 | 0 | {"x-goog-meta-#{key}", value} |
| 99 | end) | |
| 100 | end | |
| 101 | ||
| 102 | defp url(bucket) do | |
| 103 | 0 | @gs_xml_url <> "/" <> bucket |
| 104 | end | |
| 105 | ||
| 106 | defp url(bucket, key) do | |
| 107 | 0 | url(bucket) <> "/" <> key |
| 108 | end | |
| 109 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Store.Local do | |
| 1 | @behaviour Hexpm.Store | |
| 2 | ||
| 3 | # only used during development (not safe) | |
| 4 | ||
| 5 | def list(bucket, prefix) do | |
| 6 | 8 | relative = Path.join([dir(), bucket]) |
| 7 | 8 | paths = Path.join(relative, "**") |> Path.wildcard() |
| 8 | ||
| 9 | 8 | Enum.flat_map(paths, fn path -> |
| 10 | 1116 | relative = Path.relative_to(path, relative) |
| 11 | ||
| 12 | 1116 | if String.starts_with?(relative, prefix) and File.regular?(path) do |
| 13 | [relative] | |
| 14 | else | |
| 15 | [] | |
| 16 | end | |
| 17 | end) | |
| 18 | end | |
| 19 | ||
| 20 | def get(bucket, key, _opts) do | |
| 21 | 39 | path = Path.join([dir(), bucket, key]) |
| 22 | ||
| 23 | 39 | case File.read(path) do |
| 24 | 30 | {:ok, contents} -> contents |
| 25 | 9 | {:error, :enoent} -> nil |
| 26 | end | |
| 27 | end | |
| 28 | ||
| 29 | def put(bucket, key, blob, _opts) do | |
| 30 | 233 | path = Path.join([dir(), bucket, key]) |
| 31 | 233 | File.mkdir_p!(Path.dirname(path)) |
| 32 | 233 | File.write!(path, blob) |
| 33 | end | |
| 34 | ||
| 35 | def delete(bucket, key) do | |
| 36 | [dir(), bucket, key] | |
| 37 | |> Path.join() | |
| 38 | 53 | |> File.rm() |
| 39 | end | |
| 40 | ||
| 41 | def delete_many(bucket, keys) do | |
| 42 | 7 | Enum.each(keys, &delete(bucket, &1)) |
| 43 | end | |
| 44 | ||
| 45 | defp dir() do | |
| 46 | Application.get_env(:hexpm, :tmp_dir) | |
| 47 | 333 | |> Path.join("store") |
| 48 | end | |
| 49 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Store.S3 do | |
| 1 | @behaviour Hexpm.Store | |
| 2 | ||
| 3 | alias ExAws.S3 | |
| 4 | ||
| 5 | def list(bucket, prefix) do | |
| 6 | S3.list_objects(bucket(bucket), prefix: prefix) | |
| 7 | |> ExAws.stream!(region: region(bucket)) | |
| 8 | 0 | |> Stream.map(&Map.get(&1, :key)) |
| 9 | end | |
| 10 | ||
| 11 | def get(bucket, key, opts) do | |
| 12 | S3.get_object(bucket(bucket), key, opts) | |
| 13 | |> ExAws.request(region: region(bucket)) | |
| 14 | 0 | |> case do |
| 15 | 0 | {:ok, %{body: body}} -> body |
| 16 | 0 | {:error, {:http_error, 404, _}} -> nil |
| 17 | end | |
| 18 | end | |
| 19 | ||
| 20 | def put(bucket, key, blob, opts) do | |
| 21 | S3.put_object(bucket(bucket), key, blob, opts) | |
| 22 | 0 | |> ExAws.request!(region: region(bucket)) |
| 23 | end | |
| 24 | ||
| 25 | def delete(bucket, key) do | |
| 26 | S3.delete_object(bucket(bucket), key) | |
| 27 | 0 | |> ExAws.request!(region: region(bucket)) |
| 28 | end | |
| 29 | ||
| 30 | def delete_many(bucket, keys) do | |
| 31 | # AWS doesn't like concurrent delete requests | |
| 32 | keys | |
| 33 | |> Stream.chunk_every(1000, 1000, []) | |
| 34 | 0 | |> Enum.each(fn chunk -> |
| 35 | S3.delete_multiple_objects(bucket(bucket), chunk) | |
| 36 | 0 | |> ExAws.request!(region: region(bucket)) |
| 37 | end) | |
| 38 | end | |
| 39 | ||
| 40 | defp bucket(binary) when is_binary(binary) do | |
| 41 | 0 | Enum.at(String.split(binary, ",", parts: 2), 1) |
| 42 | end | |
| 43 | ||
| 44 | defp region(binary) when is_binary(binary) do | |
| 45 | 0 | Enum.at(String.split(binary, ",", parts: 2), 0) |
| 46 | end | |
| 47 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Store do | |
| 1 | @type bucket :: String.t() | {module, String.t()} | |
| 2 | @type prefix :: key | |
| 3 | @type key :: String.t() | |
| 4 | @type body :: binary | |
| 5 | @type opts :: Keyword.t() | |
| 6 | ||
| 7 | @callback list(bucket, prefix) :: [key] | |
| 8 | @callback get(bucket, key, opts) :: body | nil | |
| 9 | @callback put(bucket, key, body, opts) :: term | |
| 10 | @callback delete(bucket, key) :: term | |
| 11 | @callback delete_many(bucket, [key]) :: :ok | |
| 12 | ||
| 13 | defp impl_bucket(atom) when is_atom(atom) do | |
| 14 | 307 | impl_bucket(Application.get_env(:hexpm, atom)) |
| 15 | end | |
| 16 | ||
| 17 | 310 | defp impl_bucket({impl, bucket}) when is_atom(impl) do |
| 18 | {impl, bucket} | |
| 19 | end | |
| 20 | ||
| 21 | defp impl_bucket(bucket) when is_binary(bucket) do | |
| 22 | 0 | case String.split(bucket, ",", parts: 2) do |
| 23 | 0 | ["local", bucket] -> {Hexpm.Store.Local, bucket} |
| 24 | 0 | ["s3", bucket] -> {Hexpm.Store.S3, bucket} |
| 25 | 0 | ["gcs", bucket] -> {Hexpm.Store.GCS, bucket} |
| 26 | end | |
| 27 | end | |
| 28 | ||
| 29 | def list(bucket, prefix) do | |
| 30 | 8 | {impl, bucket} = impl_bucket(bucket) |
| 31 | 8 | impl.list(bucket, prefix) |
| 32 | end | |
| 33 | ||
| 34 | def get(bucket, key, opts) do | |
| 35 | 39 | {impl, bucket} = impl_bucket(bucket) |
| 36 | 39 | impl.get(bucket, key, opts) |
| 37 | end | |
| 38 | ||
| 39 | def put(bucket, key, body, opts) do | |
| 40 | 233 | {impl, bucket} = impl_bucket(bucket) |
| 41 | 233 | impl.put(bucket, key, body, opts) |
| 42 | end | |
| 43 | ||
| 44 | def delete(bucket, key) do | |
| 45 | 23 | {impl, bucket} = impl_bucket(bucket) |
| 46 | 23 | impl.delete(bucket, key) |
| 47 | end | |
| 48 | ||
| 49 | def delete_many(bucket, keys) do | |
| 50 | 7 | {impl, bucket} = impl_bucket(bucket) |
| 51 | 7 | impl.delete_many(bucket, keys) |
| 52 | end | |
| 53 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Utils do | |
| 1 | @moduledoc """ | |
| 2 | Assorted utility functions. | |
| 3 | """ | |
| 4 | ||
| 5 | @timeout 60 * 60 * 1000 | |
| 6 | ||
| 7 | import Ecto.Query, only: [from: 2] | |
| 8 | alias Hexpm.Repository.{Package, Release, Repository} | |
| 9 | require Logger | |
| 10 | ||
| 11 | def secure_check(left, right) do | |
| 12 | 254 | if byte_size(left) == byte_size(right) do |
| 13 | 252 | secure_check(left, right, 0) == 0 |
| 14 | else | |
| 15 | false | |
| 16 | end | |
| 17 | end | |
| 18 | ||
| 19 | defp secure_check(<<left, left_rest::binary>>, <<right, right_rest::binary>>, acc) do | |
| 20 | 8064 | secure_check(left_rest, right_rest, Bitwise.bor(acc, Bitwise.bxor(left, right))) |
| 21 | end | |
| 22 | ||
| 23 | defp secure_check(<<>>, <<>>, acc) do | |
| 24 | 252 | acc |
| 25 | end | |
| 26 | ||
| 27 | def multi_task(args, fun) do | |
| 28 | args | |
| 29 | |> multi_async(fun) | |
| 30 | 0 | |> multi_await() |
| 31 | end | |
| 32 | ||
| 33 | def multi_task(funs) do | |
| 34 | funs | |
| 35 | |> multi_async() | |
| 36 | 0 | |> multi_await() |
| 37 | end | |
| 38 | ||
| 39 | def multi_async(args, fun) do | |
| 40 | 0 | Enum.map(args, fn arg -> Task.async(fn -> fun.(arg) end) end) |
| 41 | end | |
| 42 | ||
| 43 | def multi_async(funs) do | |
| 44 | 0 | Enum.map(funs, &Task.async/1) |
| 45 | end | |
| 46 | ||
| 47 | def multi_await(tasks) do | |
| 48 | 0 | Enum.map(tasks, &Task.await(&1, @timeout)) |
| 49 | end | |
| 50 | ||
| 51 | 0 | def maybe(nil, _fun), do: nil |
| 52 | 0 | def maybe(item, fun), do: fun.(item) |
| 53 | ||
| 54 | def log_error(kind, error, stacktrace) do | |
| 55 | 0 | Logger.error( |
| 56 | Exception.format_banner(kind, error, stacktrace) <> | |
| 57 | "\n" <> Exception.format_stacktrace(stacktrace) | |
| 58 | ) | |
| 59 | end | |
| 60 | ||
| 61 | def utc_yesterday() do | |
| 62 | 1 | utc_days_ago(1) |
| 63 | end | |
| 64 | ||
| 65 | def utc_days_ago(days) do | |
| 66 | 4 | {today, _time} = :calendar.universal_time() |
| 67 | ||
| 68 | today | |
| 69 | |> :calendar.date_to_gregorian_days() | |
| 70 | |> Kernel.-(days) | |
| 71 | |> :calendar.gregorian_days_to_date() | |
| 72 | 4 | |> Date.from_erl!() |
| 73 | end | |
| 74 | ||
| 75 | def safe_to_atom(binary, allowed) do | |
| 76 | 26 | if binary in allowed, do: String.to_atom(binary) |
| 77 | end | |
| 78 | ||
| 79 | 0 | def safe_page(page, _count, _per_page) when page < 1 do |
| 80 | 1 | |
| 81 | end | |
| 82 | ||
| 83 | def safe_page(page, count, per_page) when page > div(count, per_page) + 1 do | |
| 84 | 0 | div(count, per_page) + 1 |
| 85 | end | |
| 86 | ||
| 87 | def safe_page(page, _count, _per_page) do | |
| 88 | 8 | page |
| 89 | end | |
| 90 | ||
| 91 | 23 | def safe_int(nil), do: nil |
| 92 | ||
| 93 | def safe_int(string) do | |
| 94 | 3 | case Integer.parse(string) do |
| 95 | 3 | {int, ""} -> int |
| 96 | 0 | _ -> nil |
| 97 | end | |
| 98 | end | |
| 99 | ||
| 100 | 18 | def parse_search(nil), do: nil |
| 101 | 0 | def parse_search(""), do: nil |
| 102 | 8 | def parse_search(search), do: String.trim(search) |
| 103 | ||
| 104 | defp diff(a, b) do | |
| 105 | 13 | {days, time} = :calendar.time_difference(a, b) |
| 106 | 13 | :calendar.time_to_seconds(time) - days * 24 * 60 * 60 |
| 107 | end | |
| 108 | ||
| 109 | @doc """ | |
| 110 | Determine if a given timestamp is less than a day (86400 seconds) old | |
| 111 | """ | |
| 112 | 1 | def within_last_day?(nil), do: false |
| 113 | ||
| 114 | def within_last_day?(a) do | |
| 115 | 13 | diff = diff(NaiveDateTime.to_erl(a), :calendar.universal_time()) |
| 116 | ||
| 117 | 13 | diff < 24 * 60 * 60 |
| 118 | end | |
| 119 | ||
| 120 | 0 | def etag(nil), do: nil |
| 121 | 0 | def etag([]), do: nil |
| 122 | ||
| 123 | def etag(models) do | |
| 124 | 0 | list = |
| 125 | 0 | Enum.map(List.wrap(models), fn model -> |
| 126 | 0 | [model.__struct__, model.id, model.updated_at] |
| 127 | end) | |
| 128 | ||
| 129 | 0 | binary = :erlang.term_to_binary(list) |
| 130 | ||
| 131 | :crypto.hash(:md5, binary) | |
| 132 | 0 | |> Base.encode16(case: :lower) |
| 133 | end | |
| 134 | ||
| 135 | 0 | def last_modified(nil), do: nil |
| 136 | 0 | def last_modified([]), do: nil |
| 137 | ||
| 138 | def last_modified(models) do | |
| 139 | 0 | list = |
| 140 | Enum.map(List.wrap(models), fn model -> | |
| 141 | 0 | NaiveDateTime.to_erl(model.updated_at) |
| 142 | end) | |
| 143 | ||
| 144 | 0 | Enum.max(list) |
| 145 | end | |
| 146 | ||
| 147 | 4 | def binarify(term, opts \\ []) |
| 148 | ||
| 149 | 366 | def binarify(binary, _opts) when is_binary(binary), do: binary |
| 150 | 0 | def binarify(number, _opts) when is_number(number), do: number |
| 151 | 5 | def binarify(atom, _opts) when is_nil(atom) or is_boolean(atom), do: atom |
| 152 | 418 | def binarify(atom, _opts) when is_atom(atom), do: Atom.to_string(atom) |
| 153 | 152 | def binarify(list, opts) when is_list(list), do: for(elem <- list, do: binarify(elem, opts)) |
| 154 | 0 | def binarify(%Version{} = version, _opts), do: to_string(version) |
| 155 | ||
| 156 | def binarify(%DateTime{} = dt, _opts), | |
| 157 | 3 | do: dt |> DateTime.truncate(:second) |> DateTime.to_iso8601() |
| 158 | ||
| 159 | def binarify(%NaiveDateTime{} = ndt, _opts), | |
| 160 | 0 | do: ndt |> NaiveDateTime.truncate(:second) |> NaiveDateTime.to_iso8601() |
| 161 | ||
| 162 | def binarify(%{__struct__: atom}, _opts) when is_atom(atom), | |
| 163 | 0 | do: raise("not able to binarify %#{inspect(atom)}{}") |
| 164 | ||
| 165 | def binarify(tuple, opts) when is_tuple(tuple), | |
| 166 | 420 | do: for(elem <- Tuple.to_list(tuple), do: binarify(elem, opts)) |> List.to_tuple() |
| 167 | ||
| 168 | def binarify(map, opts) when is_map(map) do | |
| 169 | 101 | if Keyword.get(opts, :maps, true) do |
| 170 | 1 | for(elem <- map, into: %{}, do: binarify(elem, opts)) |
| 171 | else | |
| 172 | 100 | for(elem <- map, do: binarify(elem, opts)) |
| 173 | end | |
| 174 | end | |
| 175 | ||
| 176 | @doc """ | |
| 177 | Returns a url to a resource on the CDN from a list of path components. | |
| 178 | """ | |
| 179 | @spec cdn_url([String.t()] | String.t()) :: String.t() | |
| 180 | def cdn_url(path) do | |
| 181 | 9 | Application.get_env(:hexpm, :cdn_url) <> "/" <> Path.join(List.wrap(path)) |
| 182 | end | |
| 183 | ||
| 184 | @doc """ | |
| 185 | Returns a url to a resource on the docs site from a list of path components. | |
| 186 | """ | |
| 187 | @spec docs_html_url(Repository.t(), Package.t(), Release.t() | nil) :: String.t() | |
| 188 | def docs_html_url(%Repository{id: 1}, package, release) do | |
| 189 | 39 | docs_url = Application.get_env(:hexpm, :docs_url) |
| 190 | 39 | package = package.name |
| 191 | 39 | version = release && "#{release.version}/" |
| 192 | 39 | "#{docs_url}/#{package}/#{version}" |
| 193 | end | |
| 194 | ||
| 195 | def docs_html_url(%Repository{} = repository, package, release) do | |
| 196 | 6 | docs_url = URI.parse(Application.get_env(:hexpm, :docs_url)) |
| 197 | 6 | docs_url = %{docs_url | host: "#{repository.name}.#{docs_url.host}"} |
| 198 | 6 | package = package.name |
| 199 | 6 | version = release && "#{release.version}/" |
| 200 | 6 | "#{docs_url}/#{package}/#{version}" |
| 201 | end | |
| 202 | ||
| 203 | @doc """ | |
| 204 | Returns a url to the documentation tarball in the Amazon S3 Hex.pm bucket. | |
| 205 | """ | |
| 206 | @spec docs_tarball_url(Repository.t(), Package.t(), Release.t()) :: String.t() | |
| 207 | def docs_tarball_url(%Repository{id: 1}, package, release) do | |
| 208 | 10 | repo = Application.get_env(:hexpm, :cdn_url) |
| 209 | 10 | package = package.name |
| 210 | 10 | version = release.version |
| 211 | 10 | "#{repo}/docs/#{package}-#{version}.tar.gz" |
| 212 | end | |
| 213 | ||
| 214 | def docs_tarball_url(%Repository{} = repository, package, release) do | |
| 215 | 6 | cdn_url = Application.get_env(:hexpm, :cdn_url) |
| 216 | 6 | repository = repository.name |
| 217 | 6 | package = package.name |
| 218 | 6 | version = release.version |
| 219 | 6 | "#{cdn_url}/repos/#{repository}/docs/#{package}-#{version}.tar.gz" |
| 220 | end | |
| 221 | ||
| 222 | def paginate(query, page, count) when is_integer(page) and page > 0 do | |
| 223 | 58 | offset = (page - 1) * count |
| 224 | ||
| 225 | 58 | from( |
| 226 | var in query, | |
| 227 | offset: ^offset, | |
| 228 | limit: ^count | |
| 229 | ) | |
| 230 | end | |
| 231 | ||
| 232 | def paginate(query, _page, count) do | |
| 233 | 11 | paginate(query, 1, count) |
| 234 | end | |
| 235 | ||
| 236 | def parse_ip(ip) do | |
| 237 | 1241 | parts = String.split(ip, ".") |
| 238 | ||
| 239 | 1241 | if length(parts) == 4 do |
| 240 | 1241 | parts = Enum.map(parts, &String.to_integer/1) |
| 241 | 1241 | for part <- parts, into: <<>>, do: <<part>> |
| 242 | end | |
| 243 | end | |
| 244 | ||
| 245 | def parse_ip_mask(string) do | |
| 246 | 4 | case String.split(string, "/") do |
| 247 | 1 | [ip, mask] -> {Hexpm.Utils.parse_ip(ip), String.to_integer(mask)} |
| 248 | 3 | [ip] -> {Hexpm.Utils.parse_ip(ip), 32} |
| 249 | end | |
| 250 | end | |
| 251 | ||
| 252 | 0 | def in_ip_range?(_range, nil) do |
| 253 | false | |
| 254 | end | |
| 255 | ||
| 256 | def in_ip_range?(list, ip) when is_list(list) do | |
| 257 | 1237 | Enum.any?(list, &in_ip_range?(&1, ip)) |
| 258 | end | |
| 259 | ||
| 260 | def in_ip_range?({range, mask}, ip) do | |
| 261 | 1235 | <<range::bitstring-size(mask)>> == <<ip::bitstring-size(mask)>> |
| 262 | end | |
| 263 | ||
| 264 | def previous_version(version, all_versions) do | |
| 265 | 29 | case Enum.find_index(all_versions, &(&1 == version)) do |
| 266 | 0 | nil -> nil |
| 267 | 29 | version_index -> Enum.at(all_versions, version_index + 1) |
| 268 | end | |
| 269 | end | |
| 270 | ||
| 271 | def diff_html_url(package_name, version, previous_version) do | |
| 272 | 17 | diff_url = Application.fetch_env!(:hexpm, :diff_url) |
| 273 | 17 | "#{diff_url}/diff/#{package_name}/#{previous_version}..#{version}" |
| 274 | end | |
| 275 | ||
| 276 | def preview_html_url(package_name, version) do | |
| 277 | 30 | preview_url = Application.fetch_env!(:hexpm, :preview_url) |
| 278 | 30 | "#{preview_url}/preview/#{package_name}/#{version}" |
| 279 | end | |
| 280 | ||
| 281 | @doc """ | |
| 282 | Returns a RFC 2822 format string from a UTC datetime. | |
| 283 | """ | |
| 284 | def datetime_to_rfc2822(%DateTime{calendar: Calendar.ISO, time_zone: "Etc/UTC"} = datetime) do | |
| 285 | 17 | Calendar.strftime(datetime, "%a, %d %b %Y %H:%M:%S GMT") |
| 286 | end | |
| 287 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.AuthHelpers do | |
| 1 | import Plug.Conn | |
| 2 | import HexpmWeb.ControllerHelpers, only: [render_error: 3] | |
| 3 | ||
| 4 | alias Hexpm.Accounts.{Auth, Key, Organization, Organizations, User} | |
| 5 | alias Hexpm.Repository.{Package, Packages, PackageOwner, Repository} | |
| 6 | ||
| 7 | def authorize(conn, opts) do | |
| 8 | 291 | user_or_organization = conn.assigns.current_user || conn.assigns.current_organization |
| 9 | ||
| 10 | 291 | if user_or_organization || opts[:authentication] != :required do |
| 11 | 286 | authorized(conn, user_or_organization, opts[:fun], opts) |
| 12 | else | |
| 13 | 5 | error(conn, {:error, :missing}) |
| 14 | end | |
| 15 | end | |
| 16 | ||
| 17 | defp authorized(conn, %User{service: true}, _funs, _opts) do | |
| 18 | 1 | conn |
| 19 | end | |
| 20 | ||
| 21 | defp authorized(conn, user_or_organization, funs, opts) do | |
| 22 | 285 | domain = Keyword.get(opts, :domain) |
| 23 | 285 | resource = Keyword.get(opts, :resource) |
| 24 | 285 | key = conn.assigns.key |
| 25 | 285 | email = conn.assigns.email |
| 26 | ||
| 27 | 285 | cond do |
| 28 | not verified_user?(user_or_organization, email, opts) -> | |
| 29 | 1 | error(conn, {:error, :unconfirmed}) |
| 30 | ||
| 31 | 284 | user_or_organization && !verify_permissions?(key, domain, resource) -> |
| 32 | 0 | error(conn, {:error, :domain}) |
| 33 | ||
| 34 | 284 | funs -> |
| 35 | Enum.find_value(List.wrap(funs), fn fun -> | |
| 36 | 315 | case apply_authorization_fun(fun, conn, user_or_organization, opts[:opts]) do |
| 37 | 233 | :ok -> nil |
| 38 | 82 | other -> error(conn, other) |
| 39 | end | |
| 40 | 237 | end) || conn |
| 41 | ||
| 42 | 47 | true -> |
| 43 | 47 | conn |
| 44 | end | |
| 45 | end | |
| 46 | ||
| 47 | defp apply_authorization_fun(fun, conn, user_or_organization, _opts = nil) do | |
| 48 | 227 | fun.(conn, user_or_organization) |
| 49 | end | |
| 50 | ||
| 51 | defp apply_authorization_fun(fun, conn, user_or_organization, opts) do | |
| 52 | 88 | fun.(conn, user_or_organization, opts) |
| 53 | end | |
| 54 | ||
| 55 | defp verified_user?(%User{}, email, opts) do | |
| 56 | 203 | allow_unconfirmed = Keyword.get(opts, :allow_unconfirmed, false) |
| 57 | 203 | allow_unconfirmed || (email && email.verified) |
| 58 | end | |
| 59 | ||
| 60 | 27 | defp verified_user?(%Organization{}, _email, _opts) do |
| 61 | true | |
| 62 | end | |
| 63 | ||
| 64 | 55 | defp verified_user?(nil, _email, _opts) do |
| 65 | true | |
| 66 | end | |
| 67 | ||
| 68 | 4 | defp verify_permissions?(nil, _domain, _resource) do |
| 69 | true | |
| 70 | end | |
| 71 | ||
| 72 | 38 | defp verify_permissions?(_key, nil, _resource) do |
| 73 | true | |
| 74 | end | |
| 75 | ||
| 76 | defp verify_permissions?(key, domain, resource) do | |
| 77 | 187 | Key.verify_permissions?(key, domain, resource) |
| 78 | end | |
| 79 | ||
| 80 | def error(conn, error) do | |
| 81 | 117 | case error do |
| 82 | {:error, :missing} -> | |
| 83 | 5 | unauthorized(conn, "missing authentication information") |
| 84 | ||
| 85 | {:error, :invalid} -> | |
| 86 | 0 | unauthorized(conn, "invalid authentication information") |
| 87 | ||
| 88 | {:error, :password} -> | |
| 89 | 0 | unauthorized(conn, "invalid username and password combination") |
| 90 | ||
| 91 | {:error, :key} -> | |
| 92 | 6 | unauthorized(conn, "invalid API key") |
| 93 | ||
| 94 | {:error, :revoked_key} -> | |
| 95 | 4 | unauthorized(conn, "API key revoked") |
| 96 | ||
| 97 | {:error, :domain} -> | |
| 98 | 14 | unauthorized(conn, "key not authorized for this action") |
| 99 | ||
| 100 | {:error, :unconfirmed} -> | |
| 101 | 1 | forbidden(conn, "email not verified") |
| 102 | ||
| 103 | {:error, :auth} -> | |
| 104 | 11 | forbidden(conn, "account not authorized for this action") |
| 105 | ||
| 106 | {:error, :auth, reason} -> | |
| 107 | 5 | forbidden(conn, reason) |
| 108 | ||
| 109 | {:error, :not_found} -> | |
| 110 | 71 | HexpmWeb.ControllerHelpers.not_found(conn) |
| 111 | end | |
| 112 | end | |
| 113 | ||
| 114 | def authenticate(conn) do | |
| 115 | 320 | case get_req_header(conn, "authorization") do |
| 116 | ["Basic " <> credentials] -> | |
| 117 | 4 | basic_auth(credentials) |
| 118 | ||
| 119 | [key] -> | |
| 120 | 243 | key_auth(key, conn) |
| 121 | ||
| 122 | 73 | _ -> |
| 123 | {:error, :missing} | |
| 124 | end | |
| 125 | end | |
| 126 | ||
| 127 | defp basic_auth(credentials) do | |
| 128 | 4 | with {:ok, decoded} <- Base.decode64(credentials), |
| 129 | 4 | [username_or_email, password] <- String.split(decoded, ":", parts: 2) do |
| 130 | 4 | case Auth.password_auth(username_or_email, password) do |
| 131 | 4 | {:ok, result} -> {:ok, result} |
| 132 | 0 | :error -> {:error, :password} |
| 133 | end | |
| 134 | else | |
| 135 | _ -> | |
| 136 | {:error, :invalid} | |
| 137 | end | |
| 138 | end | |
| 139 | ||
| 140 | defp key_auth(key, conn) do | |
| 141 | 243 | case Auth.key_auth(key, usage_info(conn)) do |
| 142 | 233 | {:ok, result} -> {:ok, result} |
| 143 | 6 | :error -> {:error, :key} |
| 144 | 4 | :revoked -> {:error, :revoked_key} |
| 145 | end | |
| 146 | end | |
| 147 | ||
| 148 | defp usage_info(%{remote_ip: remote_ip} = conn) do | |
| 149 | 243 | %{ |
| 150 | ip: remote_ip, | |
| 151 | used_at: DateTime.utc_now(), | |
| 152 | user_agent: get_req_header(conn, "user-agent") | |
| 153 | } | |
| 154 | end | |
| 155 | ||
| 156 | def unauthorized(conn, reason) do | |
| 157 | conn | |
| 158 | |> put_resp_header("www-authenticate", "Basic realm=hex") | |
| 159 | 29 | |> render_error(401, message: reason) |
| 160 | end | |
| 161 | ||
| 162 | def forbidden(conn, reason) do | |
| 163 | 17 | render_error(conn, 403, message: reason) |
| 164 | end | |
| 165 | ||
| 166 | 87 | def package_owner(conn, user_or_organization, opts \\ []) |
| 167 | ||
| 168 | def package_owner(%Plug.Conn{} = conn, user_or_organization, opts) do | |
| 169 | 124 | package_owner(conn.assigns.repository, conn.assigns.package, user_or_organization, opts) |
| 170 | end | |
| 171 | ||
| 172 | def package_owner( | |
| 173 | %Repository{} = repository, | |
| 174 | %Package{} = package, | |
| 175 | %Organization{} = organization, | |
| 176 | opts | |
| 177 | ) do | |
| 178 | 4 | owner_level = opts[:owner_level] || "maintainer" |
| 179 | ||
| 180 | 4 | cond do |
| 181 | 4 | repository.organization_id == organization.id -> :ok |
| 182 | 2 | Packages.owner_with_access?(package, organization.user, owner_level) -> :ok |
| 183 | 1 | repository.id == 1 -> {:error, :auth} |
| 184 | 1 | true -> {:error, :not_found} |
| 185 | end | |
| 186 | end | |
| 187 | ||
| 188 | def package_owner(%Repository{} = repository, %Package{} = package, %User{} = user, opts) do | |
| 189 | 77 | cond do |
| 190 | 77 | Packages.owner_with_access?(package, user, opts[:owner_level] || "maintainer") -> :ok |
| 191 | 16 | repository.id == 1 -> {:error, :auth} |
| 192 | 9 | true -> {:error, :not_found} |
| 193 | end | |
| 194 | end | |
| 195 | ||
| 196 | def package_owner(%Repository{} = repository, %Package{}, nil, _opts) do | |
| 197 | 4 | if repository.id == 1 do |
| 198 | {:error, :auth} | |
| 199 | else | |
| 200 | {:error, :not_found} | |
| 201 | end | |
| 202 | end | |
| 203 | ||
| 204 | def package_owner( | |
| 205 | %Repository{} = repository, | |
| 206 | nil = _package, | |
| 207 | %Organization{} = organization, | |
| 208 | _opts | |
| 209 | ) do | |
| 210 | 1 | cond do |
| 211 | 1 | repository.id == 1 -> :ok |
| 212 | 1 | repository.organization_id == organization.id -> :ok |
| 213 | 0 | true -> {:error, :not_found} |
| 214 | end | |
| 215 | end | |
| 216 | ||
| 217 | def package_owner(%Repository{} = repository, nil = _package, %User{} = user, opts) do | |
| 218 | 26 | expected_role = PackageOwner.level_to_organization_role(opts[:owner_level] || "maintainer") |
| 219 | 26 | actual_role = Organizations.get_role(repository.organization, user) |
| 220 | ||
| 221 | 26 | cond do |
| 222 | 26 | repository.id == 1 -> :ok |
| 223 | 14 | actual_role && actual_role in Organization.role_or_higher(expected_role) -> :ok |
| 224 | 8 | actual_role -> {:error, :auth} |
| 225 | 7 | true -> {:error, :not_found} |
| 226 | end | |
| 227 | end | |
| 228 | ||
| 229 | def package_owner(%Repository{} = repository, nil = _package, nil = _user, _opts) do | |
| 230 | 3 | boolean_to_not_found(repository.id == 1) |
| 231 | end | |
| 232 | ||
| 233 | 9 | def package_owner(nil = _repository, _package, _user, _opts) do |
| 234 | {:error, :not_found} | |
| 235 | end | |
| 236 | ||
| 237 | 83 | def organization_access(conn, user_or_organization, opts \\ []) |
| 238 | ||
| 239 | def organization_access(%Plug.Conn{} = conn, user_or_organization, opts) do | |
| 240 | 113 | organization_access(conn.assigns.organization, user_or_organization, opts) |
| 241 | end | |
| 242 | ||
| 243 | def organization_access(%Organization{id: 1}, _user_or_organization, opts) do | |
| 244 | 21 | role = opts[:organization_role] || "read" |
| 245 | 21 | boolean_to_auth_error(role == "read") |
| 246 | end | |
| 247 | ||
| 248 | 26 | def organization_access(nil = _organization, _user_or_organization, _opts) do |
| 249 | :ok | |
| 250 | end | |
| 251 | ||
| 252 | def organization_access(%Organization{} = organization, user_or_organization, opts) do | |
| 253 | 66 | cond do |
| 254 | Organizations.access?( | |
| 255 | organization, | |
| 256 | user_or_organization, | |
| 257 | 66 | opts[:organization_role] || "read" |
| 258 | 28 | ) -> |
| 259 | :ok | |
| 260 | ||
| 261 | 38 | organization.id == 1 -> |
| 262 | {:error, :auth} | |
| 263 | ||
| 264 | 38 | true -> |
| 265 | {:error, :not_found} | |
| 266 | end | |
| 267 | end | |
| 268 | ||
| 269 | 141 | def organization_billing_active(conn, user_or_organization, opts \\ []) |
| 270 | ||
| 271 | def organization_billing_active(%Plug.Conn{} = conn, _user_or_organization, _opts) do | |
| 272 | 78 | organization_billing_active(conn.assigns.organization, nil) |
| 273 | end | |
| 274 | ||
| 275 | def organization_billing_active(%Organization{} = organization, _user_or_organization, _opts) do | |
| 276 | 84 | if organization.id == 1 or Organization.billing_active?(organization) do |
| 277 | :ok | |
| 278 | else | |
| 279 | 5 | {:error, :auth, "organization has no active billing subscription"} |
| 280 | end | |
| 281 | end | |
| 282 | ||
| 283 | 0 | def organization_billing_active(nil = _organization, _user_or_organization, _opts) do |
| 284 | :ok | |
| 285 | end | |
| 286 | ||
| 287 | 21 | defp boolean_to_auth_error(true), do: :ok |
| 288 | 0 | defp boolean_to_auth_error(false), do: {:error, :auth} |
| 289 | ||
| 290 | 0 | defp boolean_to_not_found(true), do: :ok |
| 291 | 3 | defp boolean_to_not_found(false), do: {:error, :not_found} |
| 292 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.ConsultFormat do | |
| 1 | def encode(map) when is_map(map) do | |
| 2 | map | |
| 3 | |> Hexpm.Utils.binarify(maps: false) | |
| 4 | 395 | |> Enum.map(&[:io_lib.print(&1) | ".\n"]) |
| 5 | 49 | |> IO.iodata_to_binary() |
| 6 | end | |
| 7 | ||
| 8 | def decode(string) when is_binary(string) do | |
| 9 | 0 | string = String.to_charlist(string) |
| 10 | ||
| 11 | 0 | case :safe_erl_term.string(string) do |
| 12 | {:ok, tokens, _line} -> | |
| 13 | 0 | try do |
| 14 | 0 | terms = :safe_erl_term.terms(tokens) |
| 15 | 0 | result = Enum.into(terms, %{}) |
| 16 | {:ok, result} | |
| 17 | rescue | |
| 18 | 0 | FunctionClauseError -> |
| 19 | {:error, "invalid terms"} | |
| 20 | ||
| 21 | 0 | ArgumentError -> |
| 22 | {:error, "not in key-value format"} | |
| 23 | end | |
| 24 | ||
| 25 | 0 | {:error, reason} -> |
| 26 | {:error, inspect(reason)} | |
| 27 | end | |
| 28 | end | |
| 29 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.AuthController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :required_params, ["domain"] | |
| 4 | plug :authorize, authentication: :required | |
| 5 | ||
| 6 | def show(conn, %{"domain" => domain} = params) do | |
| 7 | 38 | key = conn.assigns.key |
| 8 | 38 | user_or_organization = conn.assigns.current_user || conn.assigns.current_organization |
| 9 | 38 | resource = params["resource"] |
| 10 | ||
| 11 | 38 | if Key.verify_permissions?(key, domain, resource) do |
| 12 | 24 | case KeyPermission.verify_permissions(user_or_organization, domain, resource) do |
| 13 | {:ok, nil} -> | |
| 14 | 15 | send_resp(conn, 204, "") |
| 15 | ||
| 16 | {:ok, repository} -> | |
| 17 | 6 | case organization_billing_active(repository, user_or_organization) do |
| 18 | 4 | :ok -> send_resp(conn, 204, "") |
| 19 | 2 | error -> error(conn, error) |
| 20 | end | |
| 21 | ||
| 22 | :error -> | |
| 23 | 3 | error(conn, {:error, :auth}) |
| 24 | end | |
| 25 | else | |
| 26 | 14 | error(conn, {:error, :domain}) |
| 27 | end | |
| 28 | end | |
| 29 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.DocsController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :fetch_release | |
| 4 | ||
| 5 | plug :authorize, | |
| 6 | [ | |
| 7 | domain: "api", | |
| 8 | resource: "read", | |
| 9 | fun: [&organization_access/2, &organization_billing_active/2] | |
| 10 | ] | |
| 11 | when action in [:show] | |
| 12 | ||
| 13 | plug :authorize, | |
| 14 | [ | |
| 15 | domain: "api", | |
| 16 | resource: "write", | |
| 17 | fun: [&package_owner/2, &organization_billing_active/2] | |
| 18 | ] | |
| 19 | when action in [:create, :delete] | |
| 20 | ||
| 21 | def show(conn, _params) do | |
| 22 | 3 | repository = conn.assigns.repository |
| 23 | 3 | package = conn.assigns.package |
| 24 | 3 | release = conn.assigns.release |
| 25 | ||
| 26 | 3 | if release.has_docs do |
| 27 | 2 | redirect(conn, external: Hexpm.Utils.docs_tarball_url(repository, package, release)) |
| 28 | else | |
| 29 | 1 | not_found(conn) |
| 30 | end | |
| 31 | end | |
| 32 | ||
| 33 | def create(conn, %{"body" => body}) do | |
| 34 | 7 | repository = conn.assigns.repository |
| 35 | 7 | package = conn.assigns.package |
| 36 | 7 | release = conn.assigns.release |
| 37 | 7 | request_id = List.first(get_resp_header(conn, "x-request-id")) |
| 38 | ||
| 39 | 7 | log_tarball(repository.name, package.name, release.version, request_id, body) |
| 40 | 7 | Hexpm.Repository.Releases.publish_docs(package, release, body, audit: audit_data(conn)) |
| 41 | ||
| 42 | 7 | location = Hexpm.Utils.docs_tarball_url(repository, package, release) |
| 43 | ||
| 44 | conn | |
| 45 | |> put_resp_header("location", location) | |
| 46 | |> api_cache(:public) | |
| 47 | 7 | |> send_resp(201, "") |
| 48 | end | |
| 49 | ||
| 50 | def delete(conn, _params) do | |
| 51 | 2 | Hexpm.Repository.Releases.revert_docs(conn.assigns.release, audit: audit_data(conn)) |
| 52 | ||
| 53 | conn | |
| 54 | |> api_cache(:private) | |
| 55 | 2 | |> send_resp(204, "") |
| 56 | end | |
| 57 | ||
| 58 | defp log_tarball(repository, package, version, request_id, body) do | |
| 59 | 7 | filename = "#{repository}-#{package}-#{version}-#{request_id}.tar.gz" |
| 60 | 7 | key = Path.join(["debug", "docs", filename]) |
| 61 | 7 | Hexpm.Store.put(:repo_bucket, key, body, []) |
| 62 | end | |
| 63 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.IndexController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | def index(conn, _params) do | |
| 4 | 1 | render(conn, :index) |
| 5 | end | |
| 6 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.KeyController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :fetch_organization | |
| 4 | ||
| 5 | plug :authorize, | |
| 6 | [ | |
| 7 | domain: "api", | |
| 8 | resource: "write", | |
| 9 | allow_unconfirmed: true, | |
| 10 | fun: &organization_access/3, | |
| 11 | opts: [organization_role: "write"] | |
| 12 | ] | |
| 13 | when action == :create | |
| 14 | ||
| 15 | plug :authorize, | |
| 16 | [ | |
| 17 | domain: "api", | |
| 18 | resource: "write", | |
| 19 | fun: &organization_access/3, | |
| 20 | authentication: :required, | |
| 21 | opts: [organization_role: "write"] | |
| 22 | ] | |
| 23 | when action in [:delete, :delete_all] | |
| 24 | ||
| 25 | plug :authorize, | |
| 26 | [domain: "api", resource: "read", authentication: :required, fun: &organization_access/2] | |
| 27 | when action in [:index, :show] | |
| 28 | ||
| 29 | plug :require_organization_path | |
| 30 | ||
| 31 | def index(conn, _params) do | |
| 32 | 4 | user_or_organization = conn.assigns.organization || conn.assigns.current_user |
| 33 | 4 | authing_key = conn.assigns.key |
| 34 | 4 | keys = Keys.all(user_or_organization) |
| 35 | ||
| 36 | conn | |
| 37 | |> api_cache(:private) | |
| 38 | 4 | |> render(:index, keys: keys, authing_key: authing_key) |
| 39 | end | |
| 40 | ||
| 41 | def show(conn, %{"name" => name}) do | |
| 42 | 1 | user_or_organization = conn.assigns.organization || conn.assigns.current_user |
| 43 | 1 | authing_key = conn.assigns.key |
| 44 | 1 | key = Keys.get(user_or_organization, name) |
| 45 | ||
| 46 | 1 | if key do |
| 47 | 1 | when_stale(conn, key, fn conn -> |
| 48 | conn | |
| 49 | |> api_cache(:private) | |
| 50 | 1 | |> render(:show, key: key, authing_key: authing_key) |
| 51 | end) | |
| 52 | else | |
| 53 | 0 | not_found(conn) |
| 54 | end | |
| 55 | end | |
| 56 | ||
| 57 | def create(conn, params) do | |
| 58 | 6 | user_or_organization = conn.assigns.organization || conn.assigns.current_user |
| 59 | 6 | authing_key = conn.assigns.key |
| 60 | ||
| 61 | 6 | case Keys.create(user_or_organization, params, audit: audit_data(conn)) do |
| 62 | {:ok, %{key: key}} -> | |
| 63 | 4 | location = Routes.api_key_url(conn, :show, params["name"]) |
| 64 | ||
| 65 | conn | |
| 66 | |> put_resp_header("location", location) | |
| 67 | |> api_cache(:private) | |
| 68 | |> put_status(201) | |
| 69 | 4 | |> render(:show, key: key, authing_key: authing_key) |
| 70 | ||
| 71 | {:error, :key, changeset, _} -> | |
| 72 | 1 | validation_failed(conn, changeset) |
| 73 | end | |
| 74 | end | |
| 75 | ||
| 76 | def delete(conn, %{"name" => name}) do | |
| 77 | 2 | user_or_organization = conn.assigns.organization || conn.assigns.current_user |
| 78 | 2 | authing_key = conn.assigns.key |
| 79 | ||
| 80 | 2 | case Keys.revoke(user_or_organization, name, audit: audit_data(conn)) do |
| 81 | {:ok, %{key: key}} -> | |
| 82 | conn | |
| 83 | |> api_cache(:private) | |
| 84 | |> put_status(200) | |
| 85 | 2 | |> render(:delete, key: key, authing_key: authing_key) |
| 86 | ||
| 87 | _ -> | |
| 88 | 0 | not_found(conn) |
| 89 | end | |
| 90 | end | |
| 91 | ||
| 92 | def delete_all(conn, _params) do | |
| 93 | 1 | user_or_organization = conn.assigns.organization || conn.assigns.current_user |
| 94 | 1 | key = conn.assigns.key |
| 95 | 1 | {:ok, _} = Keys.revoke_all(user_or_organization, audit: audit_data(conn)) |
| 96 | ||
| 97 | conn | |
| 98 | |> put_status(200) | |
| 99 | 1 | |> render(:delete, key: Keys.get(key.id), authing_key: key) |
| 100 | end | |
| 101 | ||
| 102 | defp require_organization_path(conn, _opts) do | |
| 103 | 14 | if conn.assigns.current_organization && !conn.assigns.organization do |
| 104 | 0 | not_found(conn) |
| 105 | else | |
| 106 | 14 | conn |
| 107 | end | |
| 108 | end | |
| 109 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.OrganizationController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | alias Hexpm.Billing | |
| 3 | ||
| 4 | plug :fetch_organization | |
| 5 | ||
| 6 | plug :authorize, | |
| 7 | [domain: "api", resource: "read"] | |
| 8 | when action == :index | |
| 9 | ||
| 10 | plug :authorize, | |
| 11 | [domain: "api", resource: "read", fun: &organization_access/2] | |
| 12 | when action in [:show, :audit_logs] | |
| 13 | ||
| 14 | plug :authorize, | |
| 15 | [ | |
| 16 | domain: "api", | |
| 17 | resource: "write", | |
| 18 | fun: &organization_access/3, | |
| 19 | opts: [organization_level: "write"] | |
| 20 | ] | |
| 21 | when action == :update | |
| 22 | ||
| 23 | def index(conn, _params) do | |
| 24 | 2 | organizations = |
| 25 | 2 | Organizations.all_by_user(conn.assigns.current_user) ++ |
| 26 | 2 | current_organization(conn.assigns.current_organization) |
| 27 | ||
| 28 | conn | |
| 29 | |> api_cache(:private) | |
| 30 | 2 | |> render(:index, organizations: organizations) |
| 31 | end | |
| 32 | ||
| 33 | def show(conn, %{"organization" => name}) do | |
| 34 | 1 | organization = Organizations.get(name) |
| 35 | 1 | customer = Billing.get(name) |
| 36 | ||
| 37 | conn | |
| 38 | |> api_cache(:private) | |
| 39 | 1 | |> render(:show, organization: organization, customer: customer) |
| 40 | end | |
| 41 | ||
| 42 | def update(conn, %{"organization" => name} = params) do | |
| 43 | 2 | organization = Organizations.get(name) |
| 44 | 2 | user_count = Organizations.user_count(organization) |
| 45 | ||
| 46 | 2 | if params["seats"] >= user_count do |
| 47 | 1 | {:ok, customer} = Hexpm.Billing.update(organization.name, %{"quantity" => params["seats"]}) |
| 48 | ||
| 49 | conn | |
| 50 | |> api_cache(:private) | |
| 51 | 1 | |> render(:show, organization: organization, customer: customer) |
| 52 | else | |
| 53 | 1 | validation_failed(conn, "number of seats cannot be less than number of members") |
| 54 | end | |
| 55 | end | |
| 56 | ||
| 57 | def audit_logs(conn, params) do | |
| 58 | 1 | organization = conn.assigns.organization |
| 59 | 1 | audit_logs = AuditLogs.all_by(organization, Hexpm.Utils.safe_int(params["page"]), 100) |
| 60 | ||
| 61 | 1 | render(conn, :audit_logs, audit_logs: audit_logs) |
| 62 | end | |
| 63 | ||
| 64 | 2 | defp current_organization(nil), do: [] |
| 65 | 0 | defp current_organization(organization), do: [organization] |
| 66 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.OrganizationUserController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :fetch_organization | |
| 4 | ||
| 5 | plug :authorize, | |
| 6 | [domain: "api", resource: "read", fun: &organization_access/2] | |
| 7 | when action in [:index, :show] | |
| 8 | ||
| 9 | plug :authorize, | |
| 10 | [ | |
| 11 | domain: "api", | |
| 12 | resource: "write", | |
| 13 | fun: &organization_access/3, | |
| 14 | opts: [organization_role: "admin"] | |
| 15 | ] | |
| 16 | when action in [:create, :update, :delete] | |
| 17 | ||
| 18 | def index(conn, %{"organization" => name}) do | |
| 19 | 1 | organization = Organizations.get(name, users: :emails) |
| 20 | ||
| 21 | conn | |
| 22 | |> api_cache(:private) | |
| 23 | 1 | |> render(:index, organization_users: organization.organization_users) |
| 24 | end | |
| 25 | ||
| 26 | def show(conn, %{"organization" => name, "name" => username}) do | |
| 27 | 1 | organization = Organizations.get(name) |
| 28 | 1 | user = Users.public_get(username, [:emails]) |
| 29 | 1 | role = user && Organizations.get_role(organization, user) |
| 30 | ||
| 31 | 1 | if role do |
| 32 | conn | |
| 33 | |> api_cache(:private) | |
| 34 | 1 | |> render(:show, user: user, role: role) |
| 35 | else | |
| 36 | 0 | not_found(conn) |
| 37 | end | |
| 38 | end | |
| 39 | ||
| 40 | def create(conn, %{"organization" => name, "name" => username} = params) do | |
| 41 | 4 | organization = Organizations.get(name) |
| 42 | 4 | user_count = Organizations.user_count(organization) |
| 43 | 4 | customer = Hexpm.Billing.get(organization.name) |
| 44 | ||
| 45 | 4 | if customer["quantity"] > user_count do |
| 46 | 3 | if user = Users.public_get(username, [:emails]) do |
| 47 | 3 | params = %{"role" => params["role"]} |
| 48 | ||
| 49 | 3 | case Organizations.add_member(organization, user, params, audit: audit_data(conn)) do |
| 50 | {:ok, organization_user} -> | |
| 51 | 1 | location = Routes.api_organization_user_url(conn, :show, name, user.username) |
| 52 | ||
| 53 | conn | |
| 54 | |> api_cache(:private) | |
| 55 | |> put_resp_header("location", location) | |
| 56 | 1 | |> render(:show, user: user, role: organization_user.role) |
| 57 | ||
| 58 | {:error, :organization_user} -> | |
| 59 | 1 | validation_failed(conn, "cannot add an organization as member to an organization") |
| 60 | ||
| 61 | {:error, changeset} -> | |
| 62 | 1 | validation_failed(conn, changeset) |
| 63 | end | |
| 64 | else | |
| 65 | 0 | validation_failed(conn, %{"name" => "unknown user"}) |
| 66 | end | |
| 67 | else | |
| 68 | 1 | validation_failed(conn, "not enough seats to add member") |
| 69 | end | |
| 70 | end | |
| 71 | ||
| 72 | def update(conn, %{"organization" => name, "name" => username} = params) do | |
| 73 | 2 | organization = Organizations.get(name) |
| 74 | ||
| 75 | 2 | if user = Users.public_get(username, [:emails]) do |
| 76 | 2 | params = %{"role" => params["role"]} |
| 77 | ||
| 78 | 2 | case Organizations.change_role(organization, user, params, audit: audit_data(conn)) do |
| 79 | {:ok, organization_user} -> | |
| 80 | conn | |
| 81 | |> api_cache(:private) | |
| 82 | 1 | |> render(:show, user: user, role: organization_user.role) |
| 83 | ||
| 84 | {:error, :last_admin} -> | |
| 85 | 1 | validation_failed(conn, "cannot demote last admin member") |
| 86 | ||
| 87 | {:error, changeset} -> | |
| 88 | 0 | validation_failed(conn, changeset) |
| 89 | end | |
| 90 | else | |
| 91 | 0 | not_found(conn) |
| 92 | end | |
| 93 | end | |
| 94 | ||
| 95 | def delete(conn, %{"organization" => name, "name" => username}) do | |
| 96 | 2 | organization = Organizations.get(name) |
| 97 | 2 | user = Users.public_get(username) |
| 98 | ||
| 99 | 2 | case Organizations.remove_member(organization, user, audit: audit_data(conn)) do |
| 100 | :ok -> | |
| 101 | conn | |
| 102 | |> api_cache(:private) | |
| 103 | 1 | |> send_resp(204, "") |
| 104 | ||
| 105 | {:error, :last_member} -> | |
| 106 | 1 | validation_failed(conn, "cannot remove last member") |
| 107 | end | |
| 108 | end | |
| 109 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.OwnerController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :maybe_fetch_package | |
| 4 | ||
| 5 | plug :authorize, | |
| 6 | [domain: "api", resource: "read", fun: &organization_access/2] | |
| 7 | when action in [:index, :show] | |
| 8 | ||
| 9 | plug :authorize, | |
| 10 | [ | |
| 11 | domain: "api", | |
| 12 | resource: "write", | |
| 13 | fun: [&package_owner/3, &organization_billing_active/3], | |
| 14 | opts: [owner_level: "full"] | |
| 15 | ] | |
| 16 | when action in [:create, :delete] | |
| 17 | ||
| 18 | def index(conn, _params) do | |
| 19 | 9 | if package = conn.assigns.package do |
| 20 | 4 | owners = Owners.all(package, user: :emails) |
| 21 | ||
| 22 | conn | |
| 23 | |> api_cache(:private) | |
| 24 | 4 | |> render(:index, owners: owners) |
| 25 | else | |
| 26 | 5 | not_found(conn) |
| 27 | end | |
| 28 | end | |
| 29 | ||
| 30 | def show(conn, %{"username" => name}) do | |
| 31 | 6 | package = conn.assigns.package |
| 32 | 6 | name = URI.decode_www_form(name) |
| 33 | 6 | user = Users.public_get(name, [:emails]) |
| 34 | ||
| 35 | 6 | if package && user do |
| 36 | 3 | if owner = Owners.get(package, user) do |
| 37 | conn | |
| 38 | |> api_cache(:private) | |
| 39 | 2 | |> render(:show, owner: owner) |
| 40 | else | |
| 41 | 1 | not_found(conn) |
| 42 | end | |
| 43 | else | |
| 44 | 3 | not_found(conn) |
| 45 | end | |
| 46 | end | |
| 47 | ||
| 48 | def create(conn, %{"username" => name} = params) do | |
| 49 | 14 | if package = conn.assigns.package do |
| 50 | 13 | name = URI.decode_www_form(name) |
| 51 | 13 | new_owner = Users.public_get(name, [:emails]) |
| 52 | ||
| 53 | 13 | if new_owner do |
| 54 | 12 | case Owners.add(package, new_owner, params, audit: audit_data(conn)) do |
| 55 | {:ok, _owner} -> | |
| 56 | conn | |
| 57 | |> api_cache(:private) | |
| 58 | 10 | |> send_resp(204, "") |
| 59 | ||
| 60 | {:error, :not_member} -> | |
| 61 | 1 | validation_failed(conn, %{ |
| 62 | "username" => | |
| 63 | "cannot add owner to private package when the user is not a member of the organization" | |
| 64 | }) | |
| 65 | ||
| 66 | {:error, :not_organization_transfer} -> | |
| 67 | 1 | validation_failed(conn, %{ |
| 68 | "username" => | |
| 69 | "organization ownership can only be transferred, removing all existing owners" | |
| 70 | }) | |
| 71 | ||
| 72 | {:error, :organization_level} -> | |
| 73 | 0 | validation_failed(conn, %{ |
| 74 | "level" => "ownership level is required to be \"full\" for organization ownership" | |
| 75 | }) | |
| 76 | ||
| 77 | {:error, :organization_user_conflict} -> | |
| 78 | 0 | validation_failed(conn, %{ |
| 79 | "username" => | |
| 80 | "cannot add organization as owner until user account and organization is merged, " <> | |
| 81 | "please contact support@hex.pm to manually merge accounts" | |
| 82 | }) | |
| 83 | ||
| 84 | {:error, changeset} -> | |
| 85 | 0 | validation_failed(conn, changeset) |
| 86 | end | |
| 87 | else | |
| 88 | 1 | not_found(conn) |
| 89 | end | |
| 90 | else | |
| 91 | 1 | not_found(conn) |
| 92 | end | |
| 93 | end | |
| 94 | ||
| 95 | def delete(conn, %{"username" => name}) do | |
| 96 | 6 | if package = conn.assigns.package do |
| 97 | 5 | name = URI.decode_www_form(name) |
| 98 | 5 | remove_owner = Users.get(name) |
| 99 | ||
| 100 | 5 | if remove_owner do |
| 101 | 4 | case Owners.remove(package, remove_owner, audit: audit_data(conn)) do |
| 102 | :ok -> | |
| 103 | conn | |
| 104 | |> api_cache(:private) | |
| 105 | 3 | |> send_resp(204, "") |
| 106 | ||
| 107 | {:error, :not_owner} -> | |
| 108 | 0 | validation_failed(conn, %{"username" => "user is not an owner of package"}) |
| 109 | ||
| 110 | {:error, :last_owner} -> | |
| 111 | 1 | validation_failed(conn, %{"username" => "cannot remove last owner of package"}) |
| 112 | end | |
| 113 | else | |
| 114 | 1 | not_found(conn) |
| 115 | end | |
| 116 | else | |
| 117 | 1 | not_found(conn) |
| 118 | end | |
| 119 | end | |
| 120 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.PackageController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :fetch_repository when action in [:index] | |
| 4 | plug :maybe_fetch_package when action in [:show, :audit_logs] | |
| 5 | ||
| 6 | plug :authorize, domain: "api", resource: "read", fun: &organization_access/2 | |
| 7 | ||
| 8 | @sort_params ~w(name recent_downloads total_downloads inserted_at updated_at) | |
| 9 | ||
| 10 | def index(conn, params) do | |
| 11 | 10 | repositories = repositories(conn) |
| 12 | 10 | page = Hexpm.Utils.safe_int(params["page"]) |
| 13 | 10 | search = Hexpm.Utils.parse_search(params["search"]) |
| 14 | 10 | sort = sort(params["sort"]) |
| 15 | 10 | packages = Packages.search_with_versions(repositories, page, 100, search, sort) |
| 16 | ||
| 17 | 10 | when_stale(conn, packages, [modified: false], fn conn -> |
| 18 | conn | |
| 19 | |> api_cache(:public) | |
| 20 | 10 | |> render(:index, packages: packages) |
| 21 | end) | |
| 22 | end | |
| 23 | ||
| 24 | def show(conn, _params) do | |
| 25 | 6 | if package = conn.assigns.package do |
| 26 | 3 | when_stale(conn, package, fn conn -> |
| 27 | 3 | package = Packages.preload(package) |
| 28 | 3 | owners = Enum.map(Owners.all(package, user: :emails), & &1.user) |
| 29 | 3 | package = %{package | owners: owners} |
| 30 | ||
| 31 | conn | |
| 32 | |> api_cache(:public) | |
| 33 | 3 | |> render(:show, package: package) |
| 34 | end) | |
| 35 | else | |
| 36 | 3 | not_found(conn) |
| 37 | end | |
| 38 | end | |
| 39 | ||
| 40 | def audit_logs(conn, params) do | |
| 41 | 1 | if package = conn.assigns.package do |
| 42 | 1 | audit_logs = AuditLogs.all_by(package, Hexpm.Utils.safe_int(params["page"]), 100) |
| 43 | ||
| 44 | 1 | render(conn, :audit_logs, audit_logs: audit_logs) |
| 45 | else | |
| 46 | 0 | not_found(conn) |
| 47 | end | |
| 48 | end | |
| 49 | ||
| 50 | 8 | defp sort(nil), do: sort("name") |
| 51 | 0 | defp sort("downloads"), do: sort("total_downloads") |
| 52 | 10 | defp sort(param), do: Hexpm.Utils.safe_to_atom(param, @sort_params) |
| 53 | ||
| 54 | defp repositories(conn) do | |
| 55 | 10 | cond do |
| 56 | 10 | repository = conn.assigns.repository -> |
| 57 | [repository] | |
| 58 | ||
| 59 | 8 | user = conn.assigns.current_user -> |
| 60 | 1 | Enum.map(Users.all_organizations(user), & &1.repository) |
| 61 | ||
| 62 | 7 | organization = conn.assigns.current_organization -> |
| 63 | 0 | [Repository.hexpm(), organization.repository] |
| 64 | ||
| 65 | 7 | true -> |
| 66 | [Repository.hexpm()] | |
| 67 | end | |
| 68 | end | |
| 69 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.ReleaseController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :parse_tarball when action in [:publish] | |
| 4 | plug :maybe_fetch_release when action in [:show] | |
| 5 | plug :fetch_release when action in [:delete] | |
| 6 | plug :maybe_fetch_package when action in [:create, :publish] | |
| 7 | ||
| 8 | plug :authorize, | |
| 9 | [domain: "api", resource: "read", fun: &organization_access/2] | |
| 10 | when action in [:show] | |
| 11 | ||
| 12 | plug :authorize, | |
| 13 | [ | |
| 14 | domain: "api", | |
| 15 | resource: "write", | |
| 16 | fun: [&package_owner/2, &organization_billing_active/2] | |
| 17 | ] | |
| 18 | when action in [:create, :publish] | |
| 19 | ||
| 20 | plug :authorize, | |
| 21 | [ | |
| 22 | domain: "api", | |
| 23 | resource: "write", | |
| 24 | fun: [&package_owner/2, &organization_billing_active/2] | |
| 25 | ] | |
| 26 | when action in [:delete] | |
| 27 | ||
| 28 | @download_period_params ~w(day month all) | |
| 29 | ||
| 30 | def publish(conn, %{"body" => body} = params) do | |
| 31 | 28 | replace? = Map.get(params, "replace", true) |
| 32 | 28 | request_id = List.first(get_resp_header(conn, "x-request-id")) |
| 33 | ||
| 34 | 28 | log_tarball( |
| 35 | 28 | conn.assigns.repository.name, |
| 36 | 28 | conn.assigns.meta["name"], |
| 37 | 28 | conn.assigns.meta["version"], |
| 38 | request_id, | |
| 39 | body | |
| 40 | ) | |
| 41 | ||
| 42 | Releases.publish( | |
| 43 | 28 | conn.assigns.repository, |
| 44 | 28 | conn.assigns.package, |
| 45 | 28 | conn.assigns.current_user, |
| 46 | body, | |
| 47 | 28 | conn.assigns.meta, |
| 48 | 28 | conn.assigns.inner_checksum, |
| 49 | 28 | conn.assigns.outer_checksum, |
| 50 | audit: audit_data(conn), | |
| 51 | replace: replace? | |
| 52 | ) | |
| 53 | 28 | |> publish_result(conn) |
| 54 | end | |
| 55 | ||
| 56 | def create(conn, %{"body" => body}) do | |
| 57 | 8 | handle_tarball( |
| 58 | conn, | |
| 59 | 8 | conn.assigns.repository, |
| 60 | 8 | conn.assigns.package, |
| 61 | 8 | conn.assigns.current_user, |
| 62 | body | |
| 63 | ) | |
| 64 | end | |
| 65 | ||
| 66 | def show(conn, params) do | |
| 67 | 11 | if release = conn.assigns.release do |
| 68 | 8 | downloads_period = Hexpm.Utils.safe_to_atom(params["downloads"], @download_period_params) |
| 69 | 8 | downloads = Releases.downloads_by_period(release.id, downloads_period) |
| 70 | ||
| 71 | 8 | release = |
| 72 | release | |
| 73 | |> Releases.preload([:requirements, :publisher]) | |
| 74 | |> Map.put(:downloads, downloads) | |
| 75 | ||
| 76 | 8 | when_stale(conn, release, fn conn -> |
| 77 | conn | |
| 78 | |> api_cache(:public) | |
| 79 | 8 | |> render(:show, release: release) |
| 80 | end) | |
| 81 | else | |
| 82 | 3 | not_found(conn) |
| 83 | end | |
| 84 | end | |
| 85 | ||
| 86 | def delete(conn, _params) do | |
| 87 | 7 | package = conn.assigns.package |
| 88 | 7 | release = conn.assigns.release |
| 89 | ||
| 90 | 7 | case Releases.revert(package, release, audit: audit_data(conn)) do |
| 91 | :ok -> | |
| 92 | conn | |
| 93 | |> api_cache(:private) | |
| 94 | 5 | |> send_resp(204, "") |
| 95 | ||
| 96 | {:error, _, changeset, _} -> | |
| 97 | 2 | validation_failed(conn, changeset) |
| 98 | end | |
| 99 | end | |
| 100 | ||
| 101 | defp parse_tarball(conn, _opts) do | |
| 102 | 36 | case release_metadata(conn.params["body"]) do |
| 103 | {:ok, meta, inner_checksum, outer_checksum} -> | |
| 104 | 36 | params = Map.put(conn.params, "name", meta["name"]) |
| 105 | ||
| 106 | %{conn | params: params} | |
| 107 | |> assign(:meta, meta) | |
| 108 | |> assign(:inner_checksum, inner_checksum) | |
| 109 | 36 | |> assign(:outer_checksum, outer_checksum) |
| 110 | ||
| 111 | {:error, errors} -> | |
| 112 | 0 | validation_failed(conn, %{tar: errors}) |
| 113 | end | |
| 114 | end | |
| 115 | ||
| 116 | defp handle_tarball(conn, repository, package, user, body) do | |
| 117 | case release_metadata(body) do | |
| 118 | {:ok, meta, inner_checksum, outer_checksum} -> | |
| 119 | 8 | replace? = Map.get(conn.params, "replace", true) |
| 120 | 8 | request_id = List.first(get_resp_header(conn, "x-request-id")) |
| 121 | 8 | log_tarball(repository.name, meta["name"], meta["version"], request_id, body) |
| 122 | ||
| 123 | 8 | Releases.publish( |
| 124 | repository, | |
| 125 | package, | |
| 126 | user, | |
| 127 | body, | |
| 128 | meta, | |
| 129 | inner_checksum, | |
| 130 | outer_checksum, | |
| 131 | audit: audit_data(conn), | |
| 132 | replace: replace? | |
| 133 | ) | |
| 134 | ||
| 135 | 0 | {:error, errors} -> |
| 136 | {:error, %{tar: errors}} | |
| 137 | end | |
| 138 | 8 | |> publish_result(conn) |
| 139 | end | |
| 140 | ||
| 141 | defp publish_result({:ok, %{action: :insert, package: package, release: release}}, conn) do | |
| 142 | 19 | location = Routes.api_release_url(conn, :show, package, release) |
| 143 | ||
| 144 | conn | |
| 145 | |> put_resp_header("location", location) | |
| 146 | |> api_cache(:public) | |
| 147 | |> put_status(201) | |
| 148 | 19 | |> render(:show, release: release) |
| 149 | end | |
| 150 | ||
| 151 | defp publish_result({:ok, %{action: :update, release: release}}, conn) do | |
| 152 | conn | |
| 153 | |> api_cache(:public) | |
| 154 | 6 | |> render(:show, release: release) |
| 155 | end | |
| 156 | ||
| 157 | defp publish_result({:error, errors}, conn) do | |
| 158 | 0 | validation_failed(conn, errors) |
| 159 | end | |
| 160 | ||
| 161 | defp publish_result({:error, _, changeset, _}, conn) do | |
| 162 | 11 | validation_failed(conn, normalize_errors(changeset)) |
| 163 | end | |
| 164 | ||
| 165 | defp normalize_errors(%{changes: %{requirements: requirements}} = changeset) do | |
| 166 | 2 | requirements = |
| 167 | Enum.map(requirements, fn %{errors: errors} = req -> | |
| 168 | 2 | name = Ecto.Changeset.get_field(req, :name) |
| 169 | 2 | %{req | errors: for({_, v} <- errors, do: {name, v}, into: %{})} |
| 170 | end) | |
| 171 | ||
| 172 | 2 | put_in(changeset.changes.requirements, requirements) |
| 173 | end | |
| 174 | ||
| 175 | 9 | defp normalize_errors(changeset), do: changeset |
| 176 | ||
| 177 | defp log_tarball(repository, package, version, request_id, body) do | |
| 178 | 36 | filename = "#{repository}-#{package}-#{version}-#{request_id}.tar.gz" |
| 179 | 36 | key = Path.join(["debug", "tarballs", filename]) |
| 180 | 36 | Hexpm.Store.put(:repo_bucket, key, body, []) |
| 181 | end | |
| 182 | ||
| 183 | defp release_metadata(tarball) do | |
| 184 | 44 | case :hex_tarball.unpack(tarball, :memory) do |
| 185 | {:ok, %{inner_checksum: inner_checksum, outer_checksum: outer_checksum, metadata: metadata}} -> | |
| 186 | 44 | {:ok, metadata, inner_checksum, outer_checksum} |
| 187 | ||
| 188 | 0 | {:error, reason} -> |
| 189 | {:error, List.to_string(:hex_tarball.format_error(reason))} | |
| 190 | end | |
| 191 | end | |
| 192 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.RepositoryController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :fetch_repository when action in [:show] | |
| 4 | plug :authorize, [domain: "api", resource: "read"] when action in [:index] | |
| 5 | ||
| 6 | plug :authorize, | |
| 7 | [domain: "api", resource: "read", fun: &organization_access/2] | |
| 8 | when action in [:show] | |
| 9 | ||
| 10 | def index(conn, _params) do | |
| 11 | 2 | repositories = |
| 12 | Repositories.all_public() ++ | |
| 13 | 2 | all_by_user(conn.assigns.current_user) ++ |
| 14 | 2 | all_by_organization(conn.assigns.current_organization) |
| 15 | ||
| 16 | 2 | when_stale(conn, repositories, [modified: false], fn conn -> |
| 17 | conn | |
| 18 | |> api_cache(:logged_in) | |
| 19 | 2 | |> render(:index, repositories: repositories) |
| 20 | end) | |
| 21 | end | |
| 22 | ||
| 23 | def show(conn, _params) do | |
| 24 | 2 | repository = conn.assigns.repository |
| 25 | ||
| 26 | 2 | when_stale(conn, repository, fn conn -> |
| 27 | conn | |
| 28 | |> api_cache(show_cachability(repository)) | |
| 29 | 2 | |> render(:show, repository: repository) |
| 30 | end) | |
| 31 | end | |
| 32 | ||
| 33 | 1 | defp all_by_user(nil) do |
| 34 | [] | |
| 35 | end | |
| 36 | ||
| 37 | defp all_by_user(user) do | |
| 38 | 1 | Enum.map(Organizations.all_by_user(user, [:repository]), & &1.repository) |
| 39 | end | |
| 40 | ||
| 41 | 2 | defp all_by_organization(nil), do: [] |
| 42 | 0 | defp all_by_organization(organization), do: [organization.repository] |
| 43 | ||
| 44 | 1 | defp show_cachability(%Repository{id: 1}), do: :public |
| 45 | 1 | defp show_cachability(%Repository{}), do: :private |
| 46 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.RetirementController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :maybe_fetch_release when action in [:create, :delete] | |
| 4 | ||
| 5 | plug :authorize, | |
| 6 | [domain: "api", resource: "write", fun: &package_owner/2] | |
| 7 | when action in [:create, :delete] | |
| 8 | ||
| 9 | def create(conn, params) do | |
| 10 | 4 | package = conn.assigns.package |
| 11 | ||
| 12 | 4 | if release = conn.assigns.release do |
| 13 | 3 | case Releases.retire(package, release, params, audit: audit_data(conn)) do |
| 14 | :ok -> | |
| 15 | conn | |
| 16 | |> api_cache(:private) | |
| 17 | 3 | |> send_resp(204, "") |
| 18 | ||
| 19 | {:error, _, changeset, _} -> | |
| 20 | 0 | validation_failed(conn, changeset) |
| 21 | end | |
| 22 | else | |
| 23 | 1 | not_found(conn) |
| 24 | end | |
| 25 | end | |
| 26 | ||
| 27 | def delete(conn, _params) do | |
| 28 | 4 | package = conn.assigns.package |
| 29 | ||
| 30 | 4 | if release = conn.assigns.release do |
| 31 | 3 | Releases.unretire(package, release, audit: audit_data(conn)) |
| 32 | ||
| 33 | conn | |
| 34 | |> api_cache(:private) | |
| 35 | 3 | |> send_resp(204, "") |
| 36 | else | |
| 37 | 1 | not_found(conn) |
| 38 | end | |
| 39 | end | |
| 40 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.ShortURLController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | alias Hexpm.ShortURLs | |
| 3 | ||
| 4 | def create(conn, params) do | |
| 5 | 2 | case ShortURLs.add(params) do |
| 6 | {:ok, short_url} -> | |
| 7 | conn | |
| 8 | |> put_status(201) | |
| 9 | 1 | |> render(:show, url: Routes.short_url_url(conn, :show, short_url.short_code)) |
| 10 | ||
| 11 | {:error, changeset} -> | |
| 12 | 1 | validation_failed(conn, changeset) |
| 13 | end | |
| 14 | end | |
| 15 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.UserController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :authorize, | |
| 4 | [authentication: :required, domain: "api", resource: "read"] | |
| 5 | when action in [:test, :me, :audit_logs] | |
| 6 | ||
| 7 | def create(conn, params) do | |
| 8 | 4 | params = email_param(params) |
| 9 | ||
| 10 | 4 | case Users.add(params, audit: audit_data(conn)) do |
| 11 | {:ok, user} -> | |
| 12 | 3 | location = Routes.api_user_url(conn, :show, user.username) |
| 13 | ||
| 14 | conn | |
| 15 | |> put_resp_header("location", location) | |
| 16 | |> api_cache(:private) | |
| 17 | |> put_status(201) | |
| 18 | 3 | |> render(:show, user: user) |
| 19 | ||
| 20 | {:error, changeset} -> | |
| 21 | 1 | validation_failed(conn, changeset) |
| 22 | end | |
| 23 | end | |
| 24 | ||
| 25 | def me(conn, _params) do | |
| 26 | 2 | if user = conn.assigns.current_user do |
| 27 | 1 | when_stale(conn, user, fn conn -> |
| 28 | conn | |
| 29 | |> api_cache(:private) | |
| 30 | 1 | |> render(:me, user: user) |
| 31 | end) | |
| 32 | else | |
| 33 | 1 | not_found(conn) |
| 34 | end | |
| 35 | end | |
| 36 | ||
| 37 | def audit_logs(conn, params) do | |
| 38 | 2 | if user = conn.assigns.current_user do |
| 39 | 1 | audit_logs = AuditLogs.all_by(user, Hexpm.Utils.safe_int(params["page"]), 100) |
| 40 | ||
| 41 | 1 | render(conn, :audit_logs, audit_logs: audit_logs) |
| 42 | else | |
| 43 | 1 | not_found(conn) |
| 44 | end | |
| 45 | end | |
| 46 | ||
| 47 | def show(conn, %{"name" => name}) do | |
| 48 | 6 | user = Users.public_get(name, [:emails, owned_packages: :repository]) |
| 49 | 6 | accessible_packages = Packages.accessible_user_owned_packages(user, conn.assigns.current_user) |
| 50 | ||
| 51 | 6 | user = user && %{user | owned_packages: accessible_packages} |
| 52 | ||
| 53 | 6 | if user do |
| 54 | 5 | when_stale(conn, user, fn conn -> |
| 55 | conn | |
| 56 | |> api_cache(:private) | |
| 57 | 5 | |> render(:show, user: user) |
| 58 | end) | |
| 59 | else | |
| 60 | 1 | not_found(conn) |
| 61 | end | |
| 62 | end | |
| 63 | ||
| 64 | def test(conn, params) do | |
| 65 | 1 | show(conn, params) |
| 66 | end | |
| 67 | ||
| 68 | def reset(conn, %{"name" => name}) do | |
| 69 | 2 | Users.password_reset_init(name, audit: audit_data(conn)) |
| 70 | ||
| 71 | conn | |
| 72 | |> api_cache(:private) | |
| 73 | 2 | |> send_resp(204, "") |
| 74 | end | |
| 75 | ||
| 76 | defp email_param(params) do | |
| 77 | 4 | if email = params["email"] do |
| 78 | 3 | Map.put_new(params, "emails", [%{"email" => email}]) |
| 79 | else | |
| 80 | 1 | params |
| 81 | end | |
| 82 | end | |
| 83 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.BlogController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | Enum.each(HexpmWeb.BlogView.all_templates(), fn {slug, template} -> | |
| 4 | 0 | defp slug_to_template(unquote(slug)), do: unquote(Path.rootname(template)) |
| 5 | end) | |
| 6 | ||
| 7 | 0 | defp slug_to_template(_other), do: nil |
| 8 | ||
| 9 | def index(conn, _params) do | |
| 10 | 0 | render( |
| 11 | conn, | |
| 12 | "index.html", | |
| 13 | title: "Blog", | |
| 14 | container: "container page page-sm blog" | |
| 15 | ) | |
| 16 | end | |
| 17 | ||
| 18 | def show(conn, %{"slug" => "002-organizations-going-live"}) do | |
| 19 | 0 | redirect(conn, to: Routes.blog_path(Endpoint, :show, "organizations-going-live")) |
| 20 | end | |
| 21 | ||
| 22 | def show(conn, %{"slug" => slug}) do | |
| 23 | 0 | if template = slug_to_template(slug) do |
| 24 | 0 | render( |
| 25 | conn, | |
| 26 | 0 | "#{template}.html", |
| 27 | title: title(slug), | |
| 28 | container: "container page page-sm blog" | |
| 29 | ) | |
| 30 | else | |
| 31 | 0 | not_found(conn) |
| 32 | end | |
| 33 | end | |
| 34 | ||
| 35 | defp title(slug) do | |
| 36 | slug | |
| 37 | |> String.replace("-", " ") | |
| 38 | 0 | |> String.capitalize() |
| 39 | end | |
| 40 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.ControllerHelpers do | |
| 1 | import Plug.Conn | |
| 2 | import Phoenix.Controller | |
| 3 | ||
| 4 | alias Hexpm.Accounts.{Auth, Organizations} | |
| 5 | alias Hexpm.Repository.{Packages, Releases, Repositories} | |
| 6 | alias HexpmWeb.Router.Helpers, as: Routes | |
| 7 | ||
| 8 | @max_cache_age 60 | |
| 9 | ||
| 10 | # TODO: check privacy settings | |
| 11 | def cache(conn, control, vary) do | |
| 12 | conn | |
| 13 | |> maybe_put_resp_header("cache-control", parse_control(control)) | |
| 14 | 129 | |> maybe_put_resp_header("vary", parse_vary(vary)) |
| 15 | end | |
| 16 | ||
| 17 | def api_cache(conn, privacy) do | |
| 18 | 120 | control = [logged_in_privacy(conn, privacy), "max-age": @max_cache_age] |
| 19 | 120 | vary = ["accept", "accept-encoding"] |
| 20 | 120 | cache(conn, control, vary) |
| 21 | end | |
| 22 | ||
| 23 | defp logged_in_privacy(conn, :logged_in) do | |
| 24 | 2 | if conn.assigns.current_user, do: :private, else: :public |
| 25 | end | |
| 26 | ||
| 27 | defp logged_in_privacy(_conn, other) do | |
| 28 | 118 | other |
| 29 | end | |
| 30 | ||
| 31 | 0 | defp parse_vary(nil), do: nil |
| 32 | 129 | defp parse_vary(vary), do: Enum.map_join(vary, ", ", &"#{&1}") |
| 33 | ||
| 34 | 0 | defp parse_control(nil), do: nil |
| 35 | ||
| 36 | defp parse_control(control) do | |
| 37 | 129 | Enum.map_join(control, ", ", fn |
| 38 | 129 | atom when is_atom(atom) -> "#{atom}" |
| 39 | 129 | {key, value} -> "#{key}=#{value}" |
| 40 | end) | |
| 41 | end | |
| 42 | ||
| 43 | 0 | defp maybe_put_resp_header(conn, _header, nil), do: conn |
| 44 | 258 | defp maybe_put_resp_header(conn, header, value), do: put_resp_header(conn, header, value) |
| 45 | ||
| 46 | def render_error(conn, status, assigns \\ []) do | |
| 47 | conn | |
| 48 | |> put_status(status) | |
| 49 | |> put_layout(false) | |
| 50 | |> put_view(HexpmWeb.ErrorView) | |
| 51 | 196 | |> render(:"#{status}", assigns) |
| 52 | 196 | |> halt() |
| 53 | end | |
| 54 | ||
| 55 | def validation_failed(conn, %Ecto.Changeset{} = changeset) do | |
| 56 | 17 | errors = translate_errors(changeset) |
| 57 | 17 | render_error(conn, 422, errors: errors) |
| 58 | end | |
| 59 | ||
| 60 | def validation_failed(conn, errors) do | |
| 61 | 8 | render_error(conn, 422, errors: errors) |
| 62 | end | |
| 63 | ||
| 64 | def translate_errors(changeset) do | |
| 65 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> | |
| 66 | 51 | case {message, Keyword.fetch(opts, :type)} do |
| 67 | 1 | {"is invalid", {:ok, type}} -> type_error(type) |
| 68 | 50 | _ -> interpolate_errors(message, opts) |
| 69 | end | |
| 70 | end) | |
| 71 | 40 | |> normalize_errors() |
| 72 | end | |
| 73 | ||
| 74 | defp interpolate_errors(message, opts) do | |
| 75 | 50 | Enum.reduce(opts, message, fn {key, value}, message -> |
| 76 | 51 | pattern = "%{#{key}}" |
| 77 | ||
| 78 | 51 | if String.contains?(message, pattern) do |
| 79 | 3 | if String.Chars.impl_for(value) do |
| 80 | 3 | String.replace(message, pattern, to_string(value)) |
| 81 | else | |
| 82 | 0 | raise "Unable to translate error: #{inspect({message, opts})}" |
| 83 | end | |
| 84 | else | |
| 85 | 48 | message |
| 86 | end | |
| 87 | end) | |
| 88 | end | |
| 89 | ||
| 90 | 1 | defp type_error(type), do: "expected type #{pretty_type(type)}" |
| 91 | ||
| 92 | 0 | defp pretty_type({:array, type}), do: "list(#{pretty_type(type)})" |
| 93 | 1 | defp pretty_type({:map, type}), do: "map(#{pretty_type(type)})" |
| 94 | 1 | defp pretty_type(type), do: type |> inspect() |> String.trim_leading(":") |
| 95 | ||
| 96 | # Since Changeset.traverse_errors returns `{field: [err], ...}` | |
| 97 | # but Hex client expects `{field: err1, ...}` we normalize to the latter. | |
| 98 | defp normalize_errors(errors) do | |
| 99 | Enum.flat_map(errors, &normalize_key_value/1) | |
| 100 | 57 | |> Map.new() |
| 101 | end | |
| 102 | ||
| 103 | defp normalize_key_value({key, value}) do | |
| 104 | 68 | case value do |
| 105 | 0 | _ when value == %{} -> |
| 106 | [] | |
| 107 | ||
| 108 | [%{} | _] = value -> | |
| 109 | 11 | value = Enum.reduce(value, %{}, &Map.merge(&2, normalize_errors(&1))) |
| 110 | [{key, value}] | |
| 111 | ||
| 112 | 0 | [] -> |
| 113 | [] | |
| 114 | ||
| 115 | 6 | value when is_map(value) -> |
| 116 | [{key, normalize_errors(value)}] | |
| 117 | ||
| 118 | 51 | [value | _] -> |
| 119 | [{key, value}] | |
| 120 | end | |
| 121 | end | |
| 122 | ||
| 123 | def not_found(conn) do | |
| 124 | 114 | render_error(conn, 404) |
| 125 | end | |
| 126 | ||
| 127 | def when_stale(conn, entities, opts \\ [], fun) do | |
| 128 | 32 | etag = etag(entities) |
| 129 | 32 | modified = if Keyword.get(opts, :modified, true), do: last_modified(entities) |
| 130 | ||
| 131 | 32 | conn = |
| 132 | conn | |
| 133 | |> put_etag(etag) | |
| 134 | |> put_last_modified(modified) | |
| 135 | ||
| 136 | 32 | if fresh?(conn, etag: etag, modified: modified) do |
| 137 | 0 | send_resp(conn, 304, "") |
| 138 | else | |
| 139 | 32 | fun.(conn) |
| 140 | end | |
| 141 | end | |
| 142 | ||
| 143 | defp put_etag(conn, nil) do | |
| 144 | 0 | conn |
| 145 | end | |
| 146 | ||
| 147 | defp put_etag(conn, etag) do | |
| 148 | 32 | put_resp_header(conn, "etag", etag) |
| 149 | end | |
| 150 | ||
| 151 | defp put_last_modified(conn, nil) do | |
| 152 | 12 | conn |
| 153 | end | |
| 154 | ||
| 155 | defp put_last_modified(conn, modified) do | |
| 156 | 20 | put_resp_header(conn, "last-modified", :cowboy_clock.rfc1123(modified)) |
| 157 | end | |
| 158 | ||
| 159 | defp fresh?(conn, opts) do | |
| 160 | 32 | not expired?(conn, opts) |
| 161 | end | |
| 162 | ||
| 163 | defp expired?(conn, opts) do | |
| 164 | 32 | modified_since = List.first(get_req_header(conn, "if-modified-since")) |
| 165 | 32 | none_match = List.first(get_req_header(conn, "if-none-match")) |
| 166 | ||
| 167 | 32 | if modified_since || none_match do |
| 168 | 0 | modified_since?(modified_since, opts[:modified]) or none_match?(none_match, opts[:etag]) |
| 169 | else | |
| 170 | true | |
| 171 | end | |
| 172 | end | |
| 173 | ||
| 174 | defp modified_since?(header, last_modified) do | |
| 175 | 0 | if header && last_modified do |
| 176 | 0 | modified_since = :httpd_util.convert_request_date(String.to_charlist(header)) |
| 177 | 0 | modified_since = :calendar.datetime_to_gregorian_seconds(modified_since) |
| 178 | 0 | last_modified = :calendar.datetime_to_gregorian_seconds(last_modified) |
| 179 | 0 | last_modified > modified_since |
| 180 | else | |
| 181 | false | |
| 182 | end | |
| 183 | end | |
| 184 | ||
| 185 | defp none_match?(none_match, etag) do | |
| 186 | 0 | if none_match && etag do |
| 187 | 0 | none_match = Plug.Conn.Utils.list(none_match) |
| 188 | 0 | etag not in none_match and "*" not in none_match |
| 189 | else | |
| 190 | false | |
| 191 | end | |
| 192 | end | |
| 193 | ||
| 194 | defp etag(schemas) do | |
| 195 | 32 | binary = |
| 196 | schemas | |
| 197 | |> List.wrap() | |
| 198 | |> Enum.map(&HexpmWeb.Stale.etag/1) | |
| 199 | |> List.flatten() | |
| 200 | |> :erlang.term_to_binary() | |
| 201 | ||
| 202 | :crypto.hash(:md5, binary) | |
| 203 | 32 | |> Base.encode16(case: :lower) |
| 204 | end | |
| 205 | ||
| 206 | def last_modified(schemas) do | |
| 207 | schemas | |
| 208 | |> List.wrap() | |
| 209 | |> Enum.map(&HexpmWeb.Stale.last_modified/1) | |
| 210 | |> List.flatten() | |
| 211 | 47 | |> Enum.reject(&is_nil/1) |
| 212 | |> Enum.map(&time_to_erl/1) | |
| 213 | 20 | |> Enum.max() |
| 214 | end | |
| 215 | ||
| 216 | 1 | defp time_to_erl(%NaiveDateTime{} = datetime), do: NaiveDateTime.to_erl(datetime) |
| 217 | 34 | defp time_to_erl(%DateTime{} = datetime), do: NaiveDateTime.to_erl(datetime) |
| 218 | 9 | defp time_to_erl(%Date{} = date), do: {Date.to_erl(date), {0, 0, 0}} |
| 219 | ||
| 220 | def fetch_repository(conn, _opts) do | |
| 221 | 16 | if param = conn.params["repository"] do |
| 222 | 8 | if repository = Repositories.get(param, [:organization]) do |
| 223 | conn | |
| 224 | |> assign(:repository, repository) | |
| 225 | 8 | |> assign(:organization, repository.organization) |
| 226 | else | |
| 227 | conn | |
| 228 | |> not_found() | |
| 229 | 0 | |> halt() |
| 230 | end | |
| 231 | else | |
| 232 | conn | |
| 233 | |> assign(:repository, nil) | |
| 234 | 8 | |> assign(:organization, nil) |
| 235 | end | |
| 236 | end | |
| 237 | ||
| 238 | def fetch_organization(conn, _opts) do | |
| 239 | 56 | if param = conn.params["organization"] do |
| 240 | 41 | if organization = Organizations.get(param) do |
| 241 | 39 | assign(conn, :organization, organization) |
| 242 | else | |
| 243 | conn | |
| 244 | |> not_found() | |
| 245 | 2 | |> halt() |
| 246 | end | |
| 247 | else | |
| 248 | 15 | assign(conn, :organization, nil) |
| 249 | end | |
| 250 | end | |
| 251 | ||
| 252 | def maybe_fetch_package(conn, _opts) do | |
| 253 | 115 | repository = Repositories.get(conn.params["repository"], [:organization]) |
| 254 | 115 | package = repository && Packages.get(repository, conn.params["name"]) |
| 255 | ||
| 256 | conn | |
| 257 | |> assign(:repository, repository) | |
| 258 | |> assign(:package, package) | |
| 259 | 115 | |> assign(:organization, repository && repository.organization) |
| 260 | end | |
| 261 | ||
| 262 | def fetch_release(conn, _opts) do | |
| 263 | 27 | case Version.parse(conn.params["version"]) do |
| 264 | {:ok, version} -> | |
| 265 | 27 | repository = Repositories.get(conn.params["repository"], [:organization]) |
| 266 | 27 | package = repository && Packages.get(repository, conn.params["name"]) |
| 267 | 27 | release = package && Releases.get(package, version) |
| 268 | ||
| 269 | 27 | if release do |
| 270 | conn | |
| 271 | |> assign(:repository, repository) | |
| 272 | |> assign(:package, package) | |
| 273 | |> assign(:release, release) | |
| 274 | 26 | |> assign(:organization, repository && repository.organization) |
| 275 | else | |
| 276 | conn | |
| 277 | |> not_found() | |
| 278 | 1 | |> halt() |
| 279 | end | |
| 280 | ||
| 281 | :error -> | |
| 282 | 0 | render_error(conn, 400, message: "invalid version: #{conn.params["version"]}") |
| 283 | end | |
| 284 | end | |
| 285 | ||
| 286 | def maybe_fetch_release(conn, _opts) do | |
| 287 | 33 | case Version.parse(conn.params["version"]) do |
| 288 | {:ok, version} -> | |
| 289 | 32 | repository = Repositories.get(conn.params["repository"], [:organization]) |
| 290 | 32 | package = repository && Packages.get(repository, conn.params["name"]) |
| 291 | 32 | release = package && Releases.get(package, version) |
| 292 | ||
| 293 | conn | |
| 294 | |> assign(:repository, repository) | |
| 295 | |> assign(:package, package) | |
| 296 | |> assign(:release, release) | |
| 297 | 32 | |> assign(:organization, repository && repository.organization) |
| 298 | ||
| 299 | :error -> | |
| 300 | 1 | render_error(conn, 400, message: "invalid version: #{conn.params["version"]}") |
| 301 | end | |
| 302 | end | |
| 303 | ||
| 304 | def required_params(conn, required_param_names) do | |
| 305 | 40 | remaining = required_param_names -- Map.keys(conn.params) |
| 306 | ||
| 307 | 40 | if remaining == [] do |
| 308 | 39 | conn |
| 309 | else | |
| 310 | 1 | names = Enum.map_join(remaining, ", ", &inspect/1) |
| 311 | 1 | message = "missing required parameters: #{names}" |
| 312 | 1 | render_error(conn, 400, message: message) |
| 313 | end | |
| 314 | end | |
| 315 | ||
| 316 | def audit_data(conn) do | |
| 317 | 163 | user_or_organization = conn.assigns.current_user || conn.assigns.current_organization |
| 318 | 163 | {user_or_organization, conn.assigns.user_agent, ip_to_string(conn.remote_ip)} |
| 319 | end | |
| 320 | ||
| 321 | 0 | defp ip_to_string(nil), do: nil |
| 322 | ||
| 323 | defp ip_to_string(tuple) when is_tuple(tuple) and tuple_size(tuple) == 4, | |
| 324 | 163 | do: tuple |> Tuple.to_list() |> Enum.join(".") |
| 325 | ||
| 326 | defp ip_to_string(tuple) when is_tuple(tuple) and tuple_size(tuple) == 8, | |
| 327 | 0 | do: tuple |> Tuple.to_list() |> Enum.map(&String.to_integer(&1, 16)) |> Enum.join(":") |
| 328 | ||
| 329 | def password_auth(username, password) do | |
| 330 | 12 | case Auth.password_auth(username, password) do |
| 331 | {:ok, %{user: user, email: email}} -> | |
| 332 | 11 | if email.verified, |
| 333 | do: {:ok, user}, | |
| 334 | else: {:error, :unconfirmed} | |
| 335 | ||
| 336 | 1 | :error -> |
| 337 | {:error, :wrong} | |
| 338 | end | |
| 339 | end | |
| 340 | ||
| 341 | 1 | def auth_error_message(:wrong), do: "Invalid username, email or password." |
| 342 | ||
| 343 | 1 | def auth_error_message(:unconfirmed), |
| 344 | do: "Email has not been verified yet. You can resend the verification email below." | |
| 345 | ||
| 346 | def password_breached_message(conn, _opts) do | |
| 347 | # docs_path + anchor #password-security | |
| 348 | 0 | "The password you provided has previously been breached. " <> |
| 349 | "To increase your security, please change your password." <> | |
| 350 | 0 | "<br /><a class=\"small\" href=\"#{Routes.docs_path(conn, :faq)}#password-security\">" <> |
| 351 | "Learn more about our password security.</a>" | |
| 352 | end | |
| 353 | ||
| 354 | def requires_login(conn, _opts) do | |
| 355 | 113 | if logged_in?(conn) do |
| 356 | 105 | conn |
| 357 | else | |
| 358 | 8 | redirect(conn, to: Routes.login_path(conn, :show, return: conn.request_path)) |
| 359 | 8 | |> halt |
| 360 | end | |
| 361 | end | |
| 362 | ||
| 363 | def logged_in?(conn) do | |
| 364 | 115 | !!conn.assigns[:current_user] |
| 365 | end | |
| 366 | ||
| 367 | def nillify_params(conn, keys) do | |
| 368 | 14 | params = |
| 369 | 14 | Enum.reduce(keys, conn.params, fn key, params -> |
| 370 | 14 | case Map.fetch(conn.params, key) do |
| 371 | 1 | {:ok, value} -> Map.put(params, key, scrub_param(value)) |
| 372 | 13 | :error -> params |
| 373 | end | |
| 374 | end) | |
| 375 | ||
| 376 | 14 | %{conn | params: params} |
| 377 | end | |
| 378 | ||
| 379 | defp scrub_param(%{__struct__: mod} = struct) when is_atom(mod) do | |
| 380 | 0 | struct |
| 381 | end | |
| 382 | ||
| 383 | defp scrub_param(%{} = param) do | |
| 384 | 0 | Enum.reduce(param, %{}, fn {k, v}, acc -> |
| 385 | 0 | Map.put(acc, k, scrub_param(v)) |
| 386 | end) | |
| 387 | end | |
| 388 | ||
| 389 | defp scrub_param(param) when is_list(param) do | |
| 390 | 0 | Enum.map(param, &scrub_param/1) |
| 391 | end | |
| 392 | ||
| 393 | defp scrub_param(param) do | |
| 394 | 1 | if scrub?(param), do: nil, else: param |
| 395 | end | |
| 396 | ||
| 397 | 0 | defp scrub?(" " <> rest), do: scrub?(rest) |
| 398 | 0 | defp scrub?(""), do: true |
| 399 | 1 | defp scrub?(_), do: false |
| 400 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Dashboard.AuditLogController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :requires_login | |
| 4 | ||
| 5 | @per_page 100 | |
| 6 | ||
| 7 | def index(conn, params) do | |
| 8 | 3 | page = Hexpm.Utils.safe_int(params["page"]) || 1 |
| 9 | 3 | audit_logs = Hexpm.Accounts.AuditLogs.all_by(conn.assigns.current_user, page, @per_page) |
| 10 | 3 | count = Hexpm.Accounts.AuditLogs.count_by(conn.assigns.current_user) |
| 11 | ||
| 12 | conn | |
| 13 | 3 | |> render( |
| 14 | "index.html", | |
| 15 | title: "Dashboard - Recent activities", | |
| 16 | container: "container page dashboard", | |
| 17 | audit_logs: audit_logs, | |
| 18 | page: page, | |
| 19 | per_page: @per_page, | |
| 20 | total_count: count | |
| 21 | ) | |
| 22 | end | |
| 23 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Dashboard.EmailController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :requires_login | |
| 4 | ||
| 5 | def index(conn, _params) do | |
| 6 | 1 | render_index(conn, conn.assigns.current_user) |
| 7 | end | |
| 8 | ||
| 9 | def create(conn, %{"email" => email_params}) do | |
| 10 | 3 | user = conn.assigns.current_user |
| 11 | ||
| 12 | 3 | case Users.add_email(user, email_params, audit: audit_data(conn)) do |
| 13 | {:ok, _user} -> | |
| 14 | 2 | email = email_params["email"] |
| 15 | ||
| 16 | conn | |
| 17 | 2 | |> put_flash(:info, "A verification email has been sent to #{email}.") |
| 18 | 2 | |> redirect(to: Routes.email_path(conn, :index)) |
| 19 | ||
| 20 | {:error, changeset} -> | |
| 21 | conn | |
| 22 | |> put_status(400) | |
| 23 | 1 | |> render_index(user, changeset) |
| 24 | end | |
| 25 | end | |
| 26 | ||
| 27 | def delete(conn, %{"email" => email} = params) do | |
| 28 | 2 | case Users.remove_email(conn.assigns.current_user, params, audit: audit_data(conn)) do |
| 29 | :ok -> | |
| 30 | conn | |
| 31 | 1 | |> put_flash(:info, "Removed email #{email} from your account.") |
| 32 | 1 | |> redirect(to: Routes.email_path(conn, :index)) |
| 33 | ||
| 34 | {:error, reason} -> | |
| 35 | conn | |
| 36 | |> put_flash(:error, email_error_message(reason, email)) | |
| 37 | 1 | |> redirect(to: Routes.email_path(conn, :index)) |
| 38 | end | |
| 39 | end | |
| 40 | ||
| 41 | def primary(conn, %{"email" => email} = params) do | |
| 42 | 3 | case Users.primary_email(conn.assigns.current_user, params, audit: audit_data(conn)) do |
| 43 | :ok -> | |
| 44 | conn | |
| 45 | 2 | |> put_flash(:info, "Your primary email was changed to #{email}.") |
| 46 | 2 | |> redirect(to: Routes.email_path(conn, :index)) |
| 47 | ||
| 48 | {:error, reason} -> | |
| 49 | conn | |
| 50 | |> put_flash(:error, email_error_message(reason, email)) | |
| 51 | 1 | |> redirect(to: Routes.email_path(conn, :index)) |
| 52 | end | |
| 53 | end | |
| 54 | ||
| 55 | def public(conn, %{"email" => email} = params) do | |
| 56 | 2 | case Users.public_email(conn.assigns.current_user, params, audit: audit_data(conn)) do |
| 57 | :ok -> | |
| 58 | conn | |
| 59 | 2 | |> put_flash(:info, "Your public email was changed to #{email}.") |
| 60 | 2 | |> redirect(to: Routes.email_path(conn, :index)) |
| 61 | ||
| 62 | {:error, reason} -> | |
| 63 | conn | |
| 64 | |> put_flash(:error, email_error_message(reason, email)) | |
| 65 | 0 | |> redirect(to: Routes.email_path(conn, :index)) |
| 66 | end | |
| 67 | end | |
| 68 | ||
| 69 | def gravatar(conn, %{"email" => email} = params) do | |
| 70 | 3 | case Users.gravatar_email(conn.assigns.current_user, params, audit: audit_data(conn)) do |
| 71 | :ok -> | |
| 72 | conn | |
| 73 | 1 | |> put_flash(:info, "Your gravatar email was changed to #{email}.") |
| 74 | 1 | |> redirect(to: Routes.email_path(conn, :index)) |
| 75 | ||
| 76 | {:error, reason} -> | |
| 77 | conn | |
| 78 | |> put_flash(:error, email_error_message(reason, email)) | |
| 79 | 2 | |> redirect(to: Routes.email_path(conn, :index)) |
| 80 | end | |
| 81 | end | |
| 82 | ||
| 83 | def resend_verify(conn, %{"email" => email} = params) do | |
| 84 | 1 | case Users.resend_verify_email(conn.assigns.current_user, params) do |
| 85 | :ok -> | |
| 86 | conn | |
| 87 | 1 | |> put_flash(:info, "A verification email has been sent to #{email}.") |
| 88 | 1 | |> redirect(to: Routes.email_path(conn, :index)) |
| 89 | ||
| 90 | {:error, reason} -> | |
| 91 | conn | |
| 92 | |> put_flash(:error, email_error_message(reason, email)) | |
| 93 | 0 | |> redirect(to: Routes.email_path(conn, :index)) |
| 94 | end | |
| 95 | end | |
| 96 | ||
| 97 | defp render_index(conn, user, create_changeset \\ create_changeset()) do | |
| 98 | 2 | emails = Email.order_emails(user.emails) |
| 99 | ||
| 100 | 2 | render( |
| 101 | conn, | |
| 102 | "index.html", | |
| 103 | title: "Dashboard - Email", | |
| 104 | container: "container page dashboard", | |
| 105 | create_changeset: create_changeset, | |
| 106 | emails: emails | |
| 107 | ) | |
| 108 | end | |
| 109 | ||
| 110 | defp create_changeset() do | |
| 111 | 1 | Email.changeset(%Email{}, :create, %{}, false) |
| 112 | end | |
| 113 | ||
| 114 | 1 | defp email_error_message(:unknown_email, email), do: "Unknown email #{email}." |
| 115 | 2 | defp email_error_message(:not_verified, email), do: "Email #{email} not verified." |
| 116 | 0 | defp email_error_message(:already_verified, email), do: "Email #{email} already verified." |
| 117 | 1 | defp email_error_message(:primary, email), do: "Cannot remove primary email #{email}." |
| 118 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Dashboard.KeyController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :requires_login | |
| 4 | ||
| 5 | def index(conn, _params) do | |
| 6 | 1 | render_index(conn) |
| 7 | end | |
| 8 | ||
| 9 | def delete(conn, %{"name" => name}) do | |
| 10 | 2 | user = conn.assigns.current_user |
| 11 | ||
| 12 | 2 | case Keys.revoke(user, name, audit: audit_data(conn)) do |
| 13 | {:ok, _struct} -> | |
| 14 | conn | |
| 15 | 1 | |> put_flash(:info, "The key #{name} was revoked successfully.") |
| 16 | 1 | |> redirect(to: Routes.key_path(conn, :index)) |
| 17 | ||
| 18 | {:error, _} -> | |
| 19 | conn | |
| 20 | |> put_status(400) | |
| 21 | 1 | |> put_flash(:error, "The key #{name} was not found.") |
| 22 | 1 | |> render_index() |
| 23 | end | |
| 24 | end | |
| 25 | ||
| 26 | def create(conn, params) do | |
| 27 | 1 | user = conn.assigns.current_user |
| 28 | 1 | key_params = munge_permissions(params["key"]) |
| 29 | ||
| 30 | 1 | case Keys.create(user, key_params, audit: audit_data(conn)) do |
| 31 | {:ok, %{key: key}} -> | |
| 32 | 1 | flash = |
| 33 | 1 | "The key #{key.name} was successfully generated, " <> |
| 34 | 1 | "copy the secret \"#{key.user_secret}\", you won't be able to see it again." |
| 35 | ||
| 36 | conn | |
| 37 | |> put_flash(:info, flash) | |
| 38 | 1 | |> redirect(to: Routes.key_path(conn, :index)) |
| 39 | ||
| 40 | {:error, :key, changeset, _} -> | |
| 41 | conn | |
| 42 | |> put_status(400) | |
| 43 | 0 | |> render_index(changeset) |
| 44 | end | |
| 45 | end | |
| 46 | ||
| 47 | defp render_index(conn, changeset \\ changeset()) do | |
| 48 | 2 | user = conn.assigns.current_user |
| 49 | 2 | keys = Keys.all(user) |
| 50 | 2 | organizations = Organizations.all_by_user(user) |
| 51 | ||
| 52 | 2 | render( |
| 53 | conn, | |
| 54 | "index.html", | |
| 55 | title: "Dashboard - User keys", | |
| 56 | container: "container page dashboard", | |
| 57 | keys: keys, | |
| 58 | organizations: organizations, | |
| 59 | delete_key_path: Routes.key_path(Endpoint, :delete), | |
| 60 | create_key_path: Routes.key_path(Endpoint, :create), | |
| 61 | key_changeset: changeset | |
| 62 | ) | |
| 63 | end | |
| 64 | ||
| 65 | defp changeset() do | |
| 66 | 2 | Key.changeset(%Key{}, %{}, %{}) |
| 67 | end | |
| 68 | ||
| 69 | def munge_permissions(params) do | |
| 70 | 2 | permissions = params["permissions"] || [] |
| 71 | ||
| 72 | 2 | permissions = |
| 73 | if {"repositories", "on"} in permissions do | |
| 74 | 0 | Enum.reject(permissions, &match?({"repository", _}, &1)) |
| 75 | else | |
| 76 | 2 | permissions |
| 77 | end | |
| 78 | ||
| 79 | 2 | permissions = |
| 80 | if {"apis", "on"} in permissions do | |
| 81 | 0 | Enum.reject(permissions, &match?({"api", _}, &1)) |
| 82 | else | |
| 83 | 2 | permissions |
| 84 | end | |
| 85 | ||
| 86 | 2 | permissions = |
| 87 | Enum.flat_map(permissions, fn | |
| 88 | 0 | {"repositories", "on"} -> |
| 89 | [%{"domain" => "repositories", "resource" => nil}] | |
| 90 | ||
| 91 | 0 | {"apis", "on"} -> |
| 92 | [%{"domain" => "api", "resource" => nil}] | |
| 93 | ||
| 94 | {"api", resources} -> | |
| 95 | 0 | Enum.map(Map.keys(resources), &%{"domain" => "api", "resource" => &1}) |
| 96 | ||
| 97 | {"repository", resources} -> | |
| 98 | 0 | Enum.map(Map.keys(resources), &%{"domain" => "repository", "resource" => &1}) |
| 99 | end) | |
| 100 | ||
| 101 | 2 | put_in(params["permissions"], permissions) |
| 102 | end | |
| 103 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Dashboard.OrganizationController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | alias HexpmWeb.Dashboard.KeyController | |
| 3 | ||
| 4 | plug :requires_login | |
| 5 | ||
| 6 | def redirect_repo(conn, params) do | |
| 7 | 0 | glob = params["glob"] || [] |
| 8 | 0 | path = Routes.organization_path(conn, :new) <> "/" <> Enum.join(glob, "/") |
| 9 | ||
| 10 | conn | |
| 11 | |> put_status(301) | |
| 12 | 0 | |> redirect(to: path) |
| 13 | end | |
| 14 | ||
| 15 | def show(conn, %{"dashboard_org" => organization}) do | |
| 16 | 5 | access_organization(conn, organization, "read", fn organization -> |
| 17 | 4 | render_index(conn, organization) |
| 18 | end) | |
| 19 | end | |
| 20 | ||
| 21 | def update(conn, %{ | |
| 22 | "dashboard_org" => organization, | |
| 23 | "action" => "add_member", | |
| 24 | "organization_user" => params | |
| 25 | }) do | |
| 26 | 2 | username = params["username"] |
| 27 | ||
| 28 | 2 | access_organization(conn, organization, "admin", fn organization -> |
| 29 | 2 | user_count = Organizations.user_count(organization) |
| 30 | 2 | customer = Hexpm.Billing.get(organization.name) |
| 31 | ||
| 32 | 2 | if !customer["subscription"] || customer["quantity"] > user_count do |
| 33 | 1 | if user = Users.public_get(username, [:emails]) do |
| 34 | 1 | case Organizations.add_member(organization, user, params, audit: audit_data(conn)) do |
| 35 | {:ok, _} -> | |
| 36 | conn | |
| 37 | 1 | |> put_flash(:info, "User #{username} has been added to the organization.") |
| 38 | 1 | |> redirect(to: Routes.organization_path(conn, :show, organization)) |
| 39 | ||
| 40 | {:error, changeset} -> | |
| 41 | conn | |
| 42 | |> put_status(400) | |
| 43 | 0 | |> render_index(organization, add_member: changeset) |
| 44 | end | |
| 45 | else | |
| 46 | conn | |
| 47 | |> put_status(400) | |
| 48 | 0 | |> put_flash(:error, "Unknown user #{username}.") |
| 49 | 0 | |> render_index(organization) |
| 50 | end | |
| 51 | else | |
| 52 | conn | |
| 53 | |> put_status(400) | |
| 54 | |> put_flash(:error, "Not enough seats in organization to add member.") | |
| 55 | 1 | |> render_index(organization) |
| 56 | end | |
| 57 | end) | |
| 58 | end | |
| 59 | ||
| 60 | def update(conn, %{ | |
| 61 | "dashboard_org" => organization, | |
| 62 | "action" => "remove_member", | |
| 63 | "organization_user" => params | |
| 64 | }) do | |
| 65 | # TODO: Also remove all package ownerships on organization for removed member | |
| 66 | 1 | username = params["username"] |
| 67 | ||
| 68 | 1 | access_organization(conn, organization, "admin", fn organization -> |
| 69 | 1 | user = Users.public_get(username) |
| 70 | ||
| 71 | 1 | case Organizations.remove_member(organization, user, audit: audit_data(conn)) do |
| 72 | :ok -> | |
| 73 | conn | |
| 74 | 1 | |> put_flash(:info, "User #{username} has been removed from the organization.") |
| 75 | 1 | |> redirect(to: Routes.organization_path(conn, :show, organization)) |
| 76 | ||
| 77 | {:error, :last_member} -> | |
| 78 | conn | |
| 79 | |> put_status(400) | |
| 80 | |> put_flash(:error, "Cannot remove last member from organization.") | |
| 81 | 0 | |> render_index(organization) |
| 82 | end | |
| 83 | end) | |
| 84 | end | |
| 85 | ||
| 86 | def update(conn, %{ | |
| 87 | "dashboard_org" => organization, | |
| 88 | "action" => "change_role", | |
| 89 | "organization_user" => params | |
| 90 | }) do | |
| 91 | 1 | username = params["username"] |
| 92 | ||
| 93 | 1 | access_organization(conn, organization, "admin", fn organization -> |
| 94 | 1 | if user = Users.public_get(username) do |
| 95 | 1 | case Organizations.change_role(organization, user, params, audit: audit_data(conn)) do |
| 96 | {:ok, _} -> | |
| 97 | conn | |
| 98 | 1 | |> put_flash(:info, "User #{username}'s role has been changed to #{params["role"]}.") |
| 99 | 1 | |> redirect(to: Routes.organization_path(conn, :show, organization)) |
| 100 | ||
| 101 | {:error, :last_admin} -> | |
| 102 | conn | |
| 103 | |> put_status(400) | |
| 104 | |> put_flash(:error, "Cannot demote last admin member.") | |
| 105 | 0 | |> render_index(organization) |
| 106 | ||
| 107 | {:error, changeset} -> | |
| 108 | conn | |
| 109 | |> put_status(400) | |
| 110 | 0 | |> render_index(organization, change_role: changeset) |
| 111 | end | |
| 112 | else | |
| 113 | conn | |
| 114 | |> put_status(400) | |
| 115 | 0 | |> put_flash(:error, "Unknown user #{username}.") |
| 116 | 0 | |> render_index(organization) |
| 117 | end | |
| 118 | end) | |
| 119 | end | |
| 120 | ||
| 121 | def leave(conn, %{ | |
| 122 | "dashboard_org" => organization, | |
| 123 | "organization_name" => organization_name | |
| 124 | }) do | |
| 125 | 1 | access_organization(conn, organization, "read", fn organization -> |
| 126 | 1 | if organization.name == organization_name do |
| 127 | 1 | current_user = conn.assigns.current_user |
| 128 | ||
| 129 | 1 | case Organizations.remove_member(organization, current_user, audit: audit_data(conn)) do |
| 130 | :ok -> | |
| 131 | conn | |
| 132 | 1 | |> put_flash(:info, "You just left the the organization #{organization.name}.") |
| 133 | 1 | |> redirect(to: Routes.profile_path(conn, :index)) |
| 134 | ||
| 135 | {:error, :last_member} -> | |
| 136 | conn | |
| 137 | |> put_status(400) | |
| 138 | |> put_flash(:error, "The last member of an organization cannot leave.") | |
| 139 | 0 | |> render_index(organization) |
| 140 | end | |
| 141 | else | |
| 142 | conn | |
| 143 | |> put_status(400) | |
| 144 | |> put_flash(:error, "Invalid organization name.") | |
| 145 | 0 | |> render_index(organization) |
| 146 | end | |
| 147 | end) | |
| 148 | end | |
| 149 | ||
| 150 | def billing_token(conn, %{"dashboard_org" => organization, "token" => token}) do | |
| 151 | 2 | access_organization(conn, organization, "admin", fn organization -> |
| 152 | 2 | audit = %{audit_data: audit_data(conn), organization: organization} |
| 153 | ||
| 154 | 2 | case Hexpm.Billing.checkout(organization.name, %{payment_source: token}, audit: audit) do |
| 155 | {:ok, _} -> | |
| 156 | conn | |
| 157 | |> put_resp_header("content-type", "application/json") | |
| 158 | 2 | |> send_resp(200, Jason.encode!(%{})) |
| 159 | ||
| 160 | {:error, reason} -> | |
| 161 | conn | |
| 162 | |> put_resp_header("content-type", "application/json") | |
| 163 | 0 | |> send_resp(422, Jason.encode!(reason)) |
| 164 | end | |
| 165 | end) | |
| 166 | end | |
| 167 | ||
| 168 | def cancel_billing(conn, %{"dashboard_org" => organization}) do | |
| 169 | 3 | access_organization(conn, organization, "admin", fn organization -> |
| 170 | 3 | audit = %{audit_data: audit_data(conn), organization: organization} |
| 171 | 3 | customer = Hexpm.Billing.cancel(organization.name, audit: audit) |
| 172 | ||
| 173 | 3 | message = cancel_message(customer["subscription"]["current_period_end"]) |
| 174 | ||
| 175 | conn | |
| 176 | |> put_flash(:info, message) | |
| 177 | 3 | |> redirect(to: Routes.organization_path(conn, :show, organization)) |
| 178 | end) | |
| 179 | end | |
| 180 | ||
| 181 | def show_invoice(conn, %{"dashboard_org" => organization, "id" => id}) do | |
| 182 | 1 | access_organization(conn, organization, "admin", fn organization -> |
| 183 | 1 | id = String.to_integer(id) |
| 184 | 1 | customer = Hexpm.Billing.get(organization.name) |
| 185 | 1 | invoice_ids = Enum.map(customer["invoices"], & &1["id"]) |
| 186 | ||
| 187 | 1 | if id in invoice_ids do |
| 188 | 1 | invoice = Hexpm.Billing.invoice(id) |
| 189 | ||
| 190 | conn | |
| 191 | |> put_resp_header("content-type", "text/html") | |
| 192 | 1 | |> send_resp(200, invoice) |
| 193 | else | |
| 194 | 0 | not_found(conn) |
| 195 | end | |
| 196 | end) | |
| 197 | end | |
| 198 | ||
| 199 | def pay_invoice(conn, %{"dashboard_org" => organization, "id" => id}) do | |
| 200 | 3 | access_organization(conn, organization, "admin", fn organization -> |
| 201 | 3 | id = String.to_integer(id) |
| 202 | 3 | customer = Hexpm.Billing.get(organization.name) |
| 203 | 3 | invoice_ids = Enum.map(customer["invoices"], & &1["id"]) |
| 204 | ||
| 205 | 3 | audit = %{audit_data: audit_data(conn), organization: organization} |
| 206 | ||
| 207 | 3 | if id in invoice_ids do |
| 208 | 3 | case Hexpm.Billing.pay_invoice(id, audit: audit) do |
| 209 | :ok -> | |
| 210 | conn | |
| 211 | |> put_flash(:info, "Invoice paid.") | |
| 212 | 2 | |> redirect(to: Routes.organization_path(conn, :show, organization)) |
| 213 | ||
| 214 | {:error, reason} -> | |
| 215 | conn | |
| 216 | |> put_status(400) | |
| 217 | 1 | |> put_flash(:error, "Failed to pay invoice: #{reason["errors"]}.") |
| 218 | 1 | |> render_index(organization) |
| 219 | end | |
| 220 | else | |
| 221 | 0 | not_found(conn) |
| 222 | end | |
| 223 | end) | |
| 224 | end | |
| 225 | ||
| 226 | def update_billing(conn, %{"dashboard_org" => organization} = params) do | |
| 227 | 2 | access_organization(conn, organization, "admin", fn organization -> |
| 228 | 2 | audit = %{audit_data: audit_data(conn), organization: organization} |
| 229 | ||
| 230 | 2 | update_billing( |
| 231 | conn, | |
| 232 | organization, | |
| 233 | params, | |
| 234 | 2 | &Hexpm.Billing.update(organization.name, &1, audit: audit) |
| 235 | ) | |
| 236 | end) | |
| 237 | end | |
| 238 | ||
| 239 | def create_billing(conn, %{"dashboard_org" => organization} = params) do | |
| 240 | 2 | access_organization(conn, organization, "admin", fn organization -> |
| 241 | 2 | user_count = Organizations.user_count(organization) |
| 242 | ||
| 243 | 2 | params = |
| 244 | params | |
| 245 | 2 | |> Map.put("token", organization.name) |
| 246 | |> Map.put("quantity", user_count) | |
| 247 | ||
| 248 | 2 | audit = %{audit_data: audit_data(conn), organization: organization} |
| 249 | ||
| 250 | 2 | update_billing(conn, organization, params, &Hexpm.Billing.create(&1, audit: audit)) |
| 251 | end) | |
| 252 | end | |
| 253 | ||
| 254 | @not_enough_seats "The number of open seats cannot be less than the number of organization members." | |
| 255 | ||
| 256 | def add_seats(conn, %{"dashboard_org" => organization} = params) do | |
| 257 | 3 | access_organization(conn, organization, "admin", fn organization -> |
| 258 | 3 | user_count = Organizations.user_count(organization) |
| 259 | 3 | current_seats = String.to_integer(params["current-seats"]) |
| 260 | 3 | add_seats = String.to_integer(params["add-seats"]) |
| 261 | 3 | seats = current_seats + add_seats |
| 262 | ||
| 263 | 3 | if seats >= user_count do |
| 264 | 2 | audit = %{audit_data: audit_data(conn), organization: organization} |
| 265 | ||
| 266 | 2 | {:ok, _customer} = |
| 267 | 2 | Hexpm.Billing.update(organization.name, %{"quantity" => seats}, audit: audit) |
| 268 | ||
| 269 | conn | |
| 270 | |> put_flash(:info, "The number of open seats have been increased.") | |
| 271 | 2 | |> redirect(to: Routes.organization_path(conn, :show, organization)) |
| 272 | else | |
| 273 | conn | |
| 274 | |> put_status(400) | |
| 275 | |> put_flash(:error, @not_enough_seats) | |
| 276 | 1 | |> render_index(organization) |
| 277 | end | |
| 278 | end) | |
| 279 | end | |
| 280 | ||
| 281 | def remove_seats(conn, %{"dashboard_org" => organization} = params) do | |
| 282 | 3 | access_organization(conn, organization, "admin", fn organization -> |
| 283 | 3 | user_count = Organizations.user_count(organization) |
| 284 | 3 | seats = String.to_integer(params["seats"]) |
| 285 | ||
| 286 | 3 | if seats >= user_count do |
| 287 | 2 | audit = %{audit_data: audit_data(conn), organization: organization} |
| 288 | ||
| 289 | 2 | {:ok, _customer} = |
| 290 | 2 | Hexpm.Billing.update(organization.name, %{"quantity" => seats}, audit: audit) |
| 291 | ||
| 292 | conn | |
| 293 | |> put_flash(:info, "The number of open seats have been reduced.") | |
| 294 | 2 | |> redirect(to: Routes.organization_path(conn, :show, organization)) |
| 295 | else | |
| 296 | conn | |
| 297 | |> put_status(400) | |
| 298 | |> put_flash(:error, @not_enough_seats) | |
| 299 | 1 | |> render_index(organization) |
| 300 | end | |
| 301 | end) | |
| 302 | end | |
| 303 | ||
| 304 | def change_plan(conn, %{"dashboard_org" => organization} = params) do | |
| 305 | 2 | access_organization(conn, organization, "admin", fn organization -> |
| 306 | 2 | audit = %{audit_data: audit_data(conn), organization: organization} |
| 307 | ||
| 308 | 2 | Hexpm.Billing.change_plan(organization.name, %{"plan_id" => params["plan_id"]}, audit: audit) |
| 309 | ||
| 310 | conn | |
| 311 | 2 | |> put_flash(:info, "You have switched to the #{plan_name(params["plan_id"])} plan.") |
| 312 | 2 | |> redirect(to: Routes.organization_path(conn, :show, organization)) |
| 313 | end) | |
| 314 | end | |
| 315 | ||
| 316 | 0 | defp plan_name("organization-monthly"), do: "monthly organization" |
| 317 | 2 | defp plan_name("organization-annually"), do: "annual organization" |
| 318 | ||
| 319 | def new(conn, _params) do | |
| 320 | 0 | render_new(conn) |
| 321 | end | |
| 322 | ||
| 323 | def create(conn, params) do | |
| 324 | 2 | user = conn.assigns.current_user |
| 325 | ||
| 326 | 2 | case Organizations.create(user, params["organization"], audit: audit_data(conn)) do |
| 327 | {:ok, organization} -> | |
| 328 | conn | |
| 329 | |> put_flash(:info, "Organization created with one month free trial period active.") | |
| 330 | 1 | |> redirect(to: Routes.organization_path(conn, :show, organization)) |
| 331 | ||
| 332 | {:error, changeset} -> | |
| 333 | conn | |
| 334 | |> put_status(400) | |
| 335 | 1 | |> render_new(changeset: changeset, params: params) |
| 336 | end | |
| 337 | end | |
| 338 | ||
| 339 | defp update_billing(conn, organization, params, fun) do | |
| 340 | 4 | customer_params = |
| 341 | params | |
| 342 | |> Map.take(["email", "person", "company", "token", "quantity"]) | |
| 343 | |> Map.put_new("person", nil) | |
| 344 | |> Map.put_new("company", nil) | |
| 345 | ||
| 346 | 4 | case fun.(customer_params) do |
| 347 | {:ok, _} -> | |
| 348 | conn | |
| 349 | |> put_flash(:info, "Updated your billing information.") | |
| 350 | 4 | |> redirect(to: Routes.organization_path(conn, :show, organization)) |
| 351 | ||
| 352 | {:error, reason} -> | |
| 353 | conn | |
| 354 | |> put_status(400) | |
| 355 | |> put_flash(:error, "Failed to update billing information.") | |
| 356 | 0 | |> render_index(organization, params: params, errors: reason["errors"]) |
| 357 | end | |
| 358 | end | |
| 359 | ||
| 360 | def create_key(conn, %{"dashboard_org" => organization} = params) do | |
| 361 | 1 | access_organization(conn, organization, "write", fn organization -> |
| 362 | 1 | key_params = KeyController.munge_permissions(params["key"]) |
| 363 | ||
| 364 | 1 | case Keys.create(organization, key_params, audit: audit_data(conn)) do |
| 365 | {:ok, %{key: key}} -> | |
| 366 | 1 | flash = |
| 367 | 1 | "The key #{key.name} was successfully generated, " <> |
| 368 | 1 | "copy the secret \"#{key.user_secret}\", you won't be able to see it again." |
| 369 | ||
| 370 | conn | |
| 371 | |> put_flash(:info, flash) | |
| 372 | 1 | |> redirect(to: Routes.organization_path(conn, :show, organization)) |
| 373 | ||
| 374 | {:error, :key, changeset, _} -> | |
| 375 | conn | |
| 376 | |> put_status(400) | |
| 377 | 0 | |> render_index(organization, key_changeset: changeset) |
| 378 | end | |
| 379 | end) | |
| 380 | end | |
| 381 | ||
| 382 | def delete_key(conn, %{"dashboard_org" => organization, "name" => name}) do | |
| 383 | 2 | access_organization(conn, organization, "write", fn organization -> |
| 384 | 2 | case Keys.revoke(organization, name, audit: audit_data(conn)) do |
| 385 | {:ok, _struct} -> | |
| 386 | conn | |
| 387 | 1 | |> put_flash(:info, "The key #{name} was revoked successfully.") |
| 388 | 1 | |> redirect(to: Routes.organization_path(conn, :show, organization)) |
| 389 | ||
| 390 | {:error, _} -> | |
| 391 | conn | |
| 392 | |> put_status(400) | |
| 393 | 1 | |> put_flash(:error, "The key #{name} was not found.") |
| 394 | 1 | |> render_index(organization) |
| 395 | end | |
| 396 | end) | |
| 397 | end | |
| 398 | ||
| 399 | def update_profile(conn, %{"dashboard_org" => organization, "profile" => profile_params}) do | |
| 400 | 3 | access_organization(conn, organization, "admin", fn organization -> |
| 401 | 2 | case Users.update_profile(organization.user, profile_params, audit: audit_data(conn)) do |
| 402 | {:ok, _updated_user} -> | |
| 403 | conn | |
| 404 | |> put_flash(:info, "Profile updated successfully.") | |
| 405 | 1 | |> redirect(to: Routes.organization_path(conn, :show, organization)) |
| 406 | ||
| 407 | {:error, _} -> | |
| 408 | conn | |
| 409 | |> put_status(400) | |
| 410 | |> put_flash(:error, "Oops, something went wrong!") | |
| 411 | 1 | |> render_index(organization) |
| 412 | end | |
| 413 | end) | |
| 414 | end | |
| 415 | ||
| 416 | defp render_new(conn, opts \\ []) do | |
| 417 | 1 | render( |
| 418 | conn, | |
| 419 | "new.html", | |
| 420 | title: "Dashboard - Organization sign up", | |
| 421 | container: "container page dashboard", | |
| 422 | billing_email: nil, | |
| 423 | person: nil, | |
| 424 | company: nil, | |
| 425 | params: opts[:params], | |
| 426 | errors: opts[:errors], | |
| 427 | 1 | changeset: opts[:changeset] || create_changeset() |
| 428 | ) | |
| 429 | end | |
| 430 | ||
| 431 | defp render_index(conn, organization, opts \\ []) do | |
| 432 | 11 | user = organization.user |
| 433 | 11 | public_email = user && Enum.find(user.emails, & &1.public) |
| 434 | 11 | gravatar_email = user && Enum.find(user.emails, & &1.gravatar) |
| 435 | 11 | customer = Hexpm.Billing.get(organization.name) |
| 436 | 11 | keys = Keys.all(organization) |
| 437 | 11 | delete_key_path = Routes.organization_path(Endpoint, :delete_key, organization) |
| 438 | 11 | create_key_path = Routes.organization_path(Endpoint, :create_key, organization) |
| 439 | ||
| 440 | 11 | assigns = [ |
| 441 | title: "Dashboard - Organization", | |
| 442 | container: "container page dashboard", | |
| 443 | 11 | changeset: user && User.update_profile(user, %{}), |
| 444 | 11 | public_email: public_email && public_email.email, |
| 445 | 11 | gravatar_email: gravatar_email && gravatar_email.email, |
| 446 | organization: organization, | |
| 447 | 11 | repository: organization.repository, |
| 448 | keys: keys, | |
| 449 | params: opts[:params], | |
| 450 | errors: opts[:errors], | |
| 451 | delete_key_path: delete_key_path, | |
| 452 | create_key_path: create_key_path, | |
| 453 | 11 | key_changeset: opts[:key_changeset] || key_changeset(), |
| 454 | 11 | add_member_changeset: opts[:add_member_changeset] || add_member_changeset() |
| 455 | ] | |
| 456 | ||
| 457 | 11 | assigns = Keyword.merge(assigns, customer_assigns(customer, organization)) |
| 458 | 11 | render(conn, "index.html", assigns) |
| 459 | end | |
| 460 | ||
| 461 | 0 | defp customer_assigns(nil, _organization) do |
| 462 | [ | |
| 463 | billing_started?: false, | |
| 464 | billing_active?: false, | |
| 465 | checkout_html: nil, | |
| 466 | billing_email: nil, | |
| 467 | plan_id: "organization-monthly", | |
| 468 | subscription: nil, | |
| 469 | monthly_cost: nil, | |
| 470 | amount_with_tax: nil, | |
| 471 | quantity: nil, | |
| 472 | max_period_quantity: nil, | |
| 473 | card: nil, | |
| 474 | invoices: nil, | |
| 475 | person: nil, | |
| 476 | company: nil, | |
| 477 | post_action: nil, | |
| 478 | csrf_token: nil | |
| 479 | ] | |
| 480 | end | |
| 481 | ||
| 482 | defp customer_assigns(customer, organization) do | |
| 483 | 11 | post_action = Routes.organization_path(Endpoint, :billing_token, organization) |
| 484 | ||
| 485 | [ | |
| 486 | billing_started?: true, | |
| 487 | 11 | billing_active?: !!customer["subscription"], |
| 488 | checkout_html: customer["checkout_html"], | |
| 489 | billing_email: customer["email"], | |
| 490 | plan_id: customer["plan_id"], | |
| 491 | proration_amount: customer["proration_amount"], | |
| 492 | proration_days: customer["proration_days"], | |
| 493 | subscription: customer["subscription"], | |
| 494 | monthly_cost: customer["monthly_cost"], | |
| 495 | amount_with_tax: customer["amount_with_tax"], | |
| 496 | quantity: customer["quantity"], | |
| 497 | max_period_quantity: customer["max_period_quantity"], | |
| 498 | tax_rate: customer["tax_rate"], | |
| 499 | discount: customer["discount"], | |
| 500 | card: customer["card"], | |
| 501 | invoices: customer["invoices"], | |
| 502 | person: customer["person"], | |
| 503 | company: customer["company"], | |
| 504 | post_action: post_action, | |
| 505 | csrf_token: get_csrf_token() | |
| 506 | ] | |
| 507 | end | |
| 508 | ||
| 509 | defp access_organization(conn, organization, role, fun) do | |
| 510 | 37 | user = conn.assigns.current_user |
| 511 | ||
| 512 | 37 | organization = |
| 513 | Organizations.get(organization, [ | |
| 514 | :user, | |
| 515 | :organization_users, | |
| 516 | user: :emails, | |
| 517 | users: :emails, | |
| 518 | repository: :packages | |
| 519 | ]) | |
| 520 | ||
| 521 | 37 | if organization do |
| 522 | 37 | if repo_user = Enum.find(organization.organization_users, &(&1.user_id == user.id)) do |
| 523 | 36 | if repo_user.role in Organization.role_or_higher(role) do |
| 524 | 35 | fun.(organization) |
| 525 | else | |
| 526 | conn | |
| 527 | |> put_status(400) | |
| 528 | |> put_flash(:error, "You do not have permission for this action.") | |
| 529 | 1 | |> render_index(organization) |
| 530 | end | |
| 531 | else | |
| 532 | 1 | not_found(conn) |
| 533 | end | |
| 534 | else | |
| 535 | 0 | not_found(conn) |
| 536 | end | |
| 537 | end | |
| 538 | ||
| 539 | defp add_member_changeset() do | |
| 540 | 11 | Organization.add_member(%OrganizationUser{}, %{}) |
| 541 | end | |
| 542 | ||
| 543 | defp create_changeset() do | |
| 544 | 0 | Organization.changeset(%Organization{}, %{}) |
| 545 | end | |
| 546 | ||
| 547 | defp key_changeset() do | |
| 548 | 11 | Key.changeset(%Key{}, %{}, %{}) |
| 549 | end | |
| 550 | ||
| 551 | 1 | defp cancel_message(nil = _cancel_date) do |
| 552 | "Your subscription is cancelled" | |
| 553 | end | |
| 554 | ||
| 555 | defp cancel_message(cancel_date) do | |
| 556 | 2 | date = HexpmWeb.Dashboard.OrganizationView.payment_date(cancel_date) |
| 557 | ||
| 558 | 2 | "Your subscription is cancelled, you will have access to the organization until " <> |
| 559 | 2 | "the end of your billing period at #{date}" |
| 560 | end | |
| 561 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Dashboard.PasswordController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :requires_login | |
| 4 | ||
| 5 | def index(conn, _params) do | |
| 6 | 1 | user = conn.assigns.current_user |
| 7 | 1 | render_index(conn, User.update_password(user, %{})) |
| 8 | end | |
| 9 | ||
| 10 | def update(conn, params) do | |
| 11 | 4 | user = conn.assigns.current_user |
| 12 | ||
| 13 | 4 | case Users.update_password(user, params["user"], audit: audit_data(conn)) do |
| 14 | {:ok, _user} -> | |
| 15 | 1 | breached? = Hexpm.Pwned.password_breached?(params["user"]["password"]) |
| 16 | ||
| 17 | conn | |
| 18 | |> put_flash(:info, "Your password has been updated.") | |
| 19 | |> maybe_put_flash(breached?) | |
| 20 | 1 | |> redirect(to: Routes.dashboard_password_path(conn, :index)) |
| 21 | ||
| 22 | {:error, changeset} -> | |
| 23 | conn | |
| 24 | |> put_status(400) | |
| 25 | 3 | |> render_index(changeset) |
| 26 | end | |
| 27 | end | |
| 28 | ||
| 29 | defp render_index(conn, changeset) do | |
| 30 | 4 | render( |
| 31 | conn, | |
| 32 | "index.html", | |
| 33 | title: "Dashboard - Change password", | |
| 34 | container: "container page dashboard", | |
| 35 | changeset: changeset | |
| 36 | ) | |
| 37 | end | |
| 38 | ||
| 39 | 1 | defp maybe_put_flash(conn, false), do: conn |
| 40 | ||
| 41 | defp maybe_put_flash(conn, true) do | |
| 42 | 0 | put_flash(conn, :raw_error, password_breached_message(conn, [])) |
| 43 | end | |
| 44 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Dashboard.ProfileController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :requires_login | |
| 4 | ||
| 5 | def index(conn, _params) do | |
| 6 | 1 | user = conn.assigns.current_user |
| 7 | 1 | render_index(conn, User.update_profile(user, %{})) |
| 8 | end | |
| 9 | ||
| 10 | def update(conn, params) do | |
| 11 | 5 | user = conn.assigns.current_user |
| 12 | ||
| 13 | 5 | case Users.update_profile(user, params["user"], audit: audit_data(conn)) do |
| 14 | {:ok, _user} -> | |
| 15 | conn | |
| 16 | |> put_flash(:info, "Profile updated successfully.") | |
| 17 | 5 | |> redirect(to: Routes.profile_path(conn, :index)) |
| 18 | ||
| 19 | {:error, changeset} -> | |
| 20 | conn | |
| 21 | |> put_status(400) | |
| 22 | 0 | |> render_index(changeset) |
| 23 | end | |
| 24 | end | |
| 25 | ||
| 26 | defp render_index(conn, changeset) do | |
| 27 | 1 | render( |
| 28 | conn, | |
| 29 | "index.html", | |
| 30 | title: "Dashboard - Public profile", | |
| 31 | container: "container page dashboard", | |
| 32 | changeset: changeset | |
| 33 | ) | |
| 34 | end | |
| 35 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Dashboard.SecurityController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | alias Hexpm.Accounts.User | |
| 3 | ||
| 4 | plug :requires_login | |
| 5 | ||
| 6 | def index(conn, _params) do | |
| 7 | 2 | user = conn.assigns.current_user |
| 8 | ||
| 9 | 2 | if User.tfa_enabled?(user) and not user.tfa.app_enabled do |
| 10 | conn | |
| 11 | |> put_flash(:error, "Please complete your two-factor authentication setup") | |
| 12 | 1 | |> redirect(to: Routes.dashboard_tfa_setup_path(conn, :index)) |
| 13 | else | |
| 14 | 1 | render_index(conn) |
| 15 | end | |
| 16 | end | |
| 17 | ||
| 18 | def enable_tfa(conn, _params) do | |
| 19 | 1 | user = conn.assigns.current_user |
| 20 | 1 | Users.tfa_enable(user, audit: audit_data(conn)) |
| 21 | ||
| 22 | conn | |
| 23 | |> put_flash(:info, "Two factor authentication has been enabled.") | |
| 24 | 1 | |> redirect(to: Routes.dashboard_tfa_setup_path(conn, :index)) |
| 25 | end | |
| 26 | ||
| 27 | def disable_tfa(conn, _params) do | |
| 28 | 1 | user = conn.assigns.current_user |
| 29 | 1 | Users.tfa_disable(user, audit: audit_data(conn)) |
| 30 | ||
| 31 | conn | |
| 32 | |> put_flash(:info, "Two factor authentication has been disabled.") | |
| 33 | 1 | |> redirect(to: Routes.dashboard_security_path(conn, :index)) |
| 34 | end | |
| 35 | ||
| 36 | def rotate_recovery_codes(conn, _params) do | |
| 37 | 1 | user = conn.assigns.current_user |
| 38 | 1 | Users.tfa_rotate_recovery_codes(user, audit: audit_data(conn)) |
| 39 | ||
| 40 | conn | |
| 41 | |> put_flash(:info, "New two-factor recovery codes successfully generated.") | |
| 42 | 1 | |> redirect(to: Routes.dashboard_security_path(conn, :index)) |
| 43 | end | |
| 44 | ||
| 45 | def reset_auth_app(conn, _params) do | |
| 46 | 1 | user = conn.assigns.current_user |
| 47 | 1 | Users.tfa_disable_app(user, audit: audit_data(conn)) |
| 48 | ||
| 49 | conn | |
| 50 | |> put_flash(:info, "Please complete your two-factor authentication setup") | |
| 51 | 1 | |> redirect(to: Routes.dashboard_tfa_setup_path(conn, :index)) |
| 52 | end | |
| 53 | ||
| 54 | defp render_index(conn) do | |
| 55 | 1 | render( |
| 56 | conn, | |
| 57 | "index.html", | |
| 58 | title: "Dashboard - Security", | |
| 59 | container: "container page dashboard" | |
| 60 | ) | |
| 61 | end | |
| 62 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Dashboard.TFAAuthSetupController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :requires_login | |
| 4 | ||
| 5 | def index(conn, _params) do | |
| 6 | 1 | render( |
| 7 | conn, | |
| 8 | "index.html", | |
| 9 | title: "Dashboard - Two-factor authentication setup", | |
| 10 | container: "container page dashboard" | |
| 11 | ) | |
| 12 | end | |
| 13 | ||
| 14 | def create(conn, %{"verification_code" => verification_code}) do | |
| 15 | 2 | user = conn.assigns.current_user |
| 16 | ||
| 17 | 2 | case Users.tfa_enable_app(user, verification_code, audit: audit_data(conn)) do |
| 18 | {:ok, _user} -> | |
| 19 | conn | |
| 20 | |> put_flash(:info, "Two-factor authentication has been enabled.") | |
| 21 | 1 | |> redirect(to: Routes.dashboard_security_path(conn, :index)) |
| 22 | ||
| 23 | :error -> | |
| 24 | conn | |
| 25 | |> put_flash(:error, "Your verification code was incorrect.") | |
| 26 | 1 | |> redirect(to: Routes.dashboard_tfa_setup_path(conn, :index)) |
| 27 | end | |
| 28 | end | |
| 29 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.DashboardController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :requires_login | |
| 4 | ||
| 5 | def index(conn, _params) do | |
| 6 | 1 | redirect(conn, to: Routes.profile_path(conn, :index)) |
| 7 | end | |
| 8 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.DocsController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | def index(conn, _params) do | |
| 4 | 0 | redirect(conn, to: Routes.docs_path(conn, :usage)) |
| 5 | end | |
| 6 | ||
| 7 | def usage(conn, _params) do | |
| 8 | 0 | render( |
| 9 | conn, | |
| 10 | "layout.html", | |
| 11 | view: "usage.html", | |
| 12 | view_name: :usage, | |
| 13 | title: "Mix usage", | |
| 14 | container: "container page docs" | |
| 15 | ) | |
| 16 | end | |
| 17 | ||
| 18 | def publish(conn, _params) do | |
| 19 | 0 | render( |
| 20 | conn, | |
| 21 | "layout.html", | |
| 22 | view: "publish.html", | |
| 23 | view_name: :publish, | |
| 24 | title: "Mix publish package", | |
| 25 | container: "container page docs" | |
| 26 | ) | |
| 27 | end | |
| 28 | ||
| 29 | def tasks(conn, _params) do | |
| 30 | 0 | redirect(conn, external: "https://hexdocs.pm/hex") |
| 31 | end | |
| 32 | ||
| 33 | def rebar3_usage(conn, _params) do | |
| 34 | 0 | render( |
| 35 | conn, | |
| 36 | "layout.html", | |
| 37 | view: "rebar3_usage.html", | |
| 38 | view_name: :rebar3_usage, | |
| 39 | title: "Rebar3 usage", | |
| 40 | container: "container page docs" | |
| 41 | ) | |
| 42 | end | |
| 43 | ||
| 44 | def rebar3_publish(conn, _params) do | |
| 45 | 0 | render( |
| 46 | conn, | |
| 47 | "layout.html", | |
| 48 | view: "rebar3_publish.html", | |
| 49 | view_name: :rebar3_publish, | |
| 50 | title: "Rebar3 publish package", | |
| 51 | container: "container page docs" | |
| 52 | ) | |
| 53 | end | |
| 54 | ||
| 55 | def rebar3_private(conn, _params) do | |
| 56 | 0 | render( |
| 57 | conn, | |
| 58 | "layout.html", | |
| 59 | view: "rebar3_private.html", | |
| 60 | view_name: :rebar3_private, | |
| 61 | title: "Rebar3 private packages", | |
| 62 | container: "container page docs" | |
| 63 | ) | |
| 64 | end | |
| 65 | ||
| 66 | def rebar3_tasks(conn, _params) do | |
| 67 | 0 | url = "https://rebar3.org/docs/package_management/hex_package_management/" |
| 68 | 0 | redirect(conn, external: url) |
| 69 | end | |
| 70 | ||
| 71 | def private(conn, _params) do | |
| 72 | 0 | render( |
| 73 | conn, | |
| 74 | "layout.html", | |
| 75 | view: "private.html", | |
| 76 | view_name: :private, | |
| 77 | title: "Private packages", | |
| 78 | container: "container page docs" | |
| 79 | ) | |
| 80 | end | |
| 81 | ||
| 82 | def coc(conn, _params) do | |
| 83 | 0 | render( |
| 84 | conn, | |
| 85 | "layout.html", | |
| 86 | view: "coc.html", | |
| 87 | view_name: :coc, | |
| 88 | title: "Code of Conduct", | |
| 89 | container: "container page docs" | |
| 90 | ) | |
| 91 | end | |
| 92 | ||
| 93 | def faq(conn, _params) do | |
| 94 | 0 | render( |
| 95 | conn, | |
| 96 | "layout.html", | |
| 97 | view: "faq.html", | |
| 98 | view_name: :faq, | |
| 99 | title: "FAQ", | |
| 100 | container: "container page docs" | |
| 101 | ) | |
| 102 | end | |
| 103 | ||
| 104 | def mirrors(conn, _params) do | |
| 105 | 0 | render( |
| 106 | conn, | |
| 107 | "layout.html", | |
| 108 | view: "mirrors.html", | |
| 109 | view_name: :mirrors, | |
| 110 | title: "Mirrors", | |
| 111 | container: "container page docs" | |
| 112 | ) | |
| 113 | end | |
| 114 | ||
| 115 | def public_keys(conn, _params) do | |
| 116 | 0 | render( |
| 117 | conn, | |
| 118 | "layout.html", | |
| 119 | view: "public_keys.html", | |
| 120 | view_name: :public_keys, | |
| 121 | title: "Public keys", | |
| 122 | container: "container page docs" | |
| 123 | ) | |
| 124 | end | |
| 125 | ||
| 126 | def self_hosting(conn, _params) do | |
| 127 | 0 | render( |
| 128 | conn, | |
| 129 | "layout.html", | |
| 130 | view: "self_hosting.html", | |
| 131 | view_name: :self_hosting, | |
| 132 | title: "Self-hosting", | |
| 133 | container: "container page docs" | |
| 134 | ) | |
| 135 | end | |
| 136 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.EmailVerificationController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | def verify(conn, %{"username" => username, "email" => email, "key" => key}) do | |
| 4 | 6 | success = Users.verify_email(username, email, key) == :ok |
| 5 | ||
| 6 | 6 | conn = |
| 7 | if success do | |
| 8 | 3 | put_flash(conn, :info, "Your email #{email} has been verified.") |
| 9 | else | |
| 10 | 3 | put_flash(conn, :error, "Your email #{email} failed to verify.") |
| 11 | end | |
| 12 | ||
| 13 | 6 | redirect(conn, to: Routes.page_path(HexpmWeb.Endpoint, :index)) |
| 14 | end | |
| 15 | ||
| 16 | def show(conn, _params) do | |
| 17 | 1 | render( |
| 18 | conn, | |
| 19 | "show.html", | |
| 20 | title: "Verify email", | |
| 21 | container: "container page page-xs" | |
| 22 | ) | |
| 23 | end | |
| 24 | ||
| 25 | def create(conn, %{"email" => email_address}) do | |
| 26 | 3 | if email = Users.get_email(email_address, [:user]) do |
| 27 | 2 | unless email.verified do |
| 28 | 1 | Users.email_verification(email.user, email) |
| 29 | end | |
| 30 | end | |
| 31 | ||
| 32 | conn | |
| 33 | 3 | |> put_flash(:info, "A verification email has been sent to #{email_address}.") |
| 34 | 3 | |> redirect(to: Routes.page_path(HexpmWeb.Endpoint, :index)) |
| 35 | end | |
| 36 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.FeedsController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | def blog(conn, _params) do | |
| 4 | conn | |
| 5 | |> put_view(HexpmWeb.BlogView) | |
| 6 | |> put_resp_content_type("application/rss+xml") | |
| 7 | 1 | |> render("index.xml") |
| 8 | end | |
| 9 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.InstallController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | def archive(conn, params) do | |
| 4 | 9 | user_agent = get_req_header(conn, "user-agent") |
| 5 | 9 | current = params["elixir"] || version_from_user_agent(user_agent) |
| 6 | 9 | all_versions = Installs.all() |
| 7 | ||
| 8 | 9 | url = |
| 9 | case Install.latest(all_versions, current) do | |
| 10 | {:ok, _hex, elixir} -> | |
| 11 | 8 | "installs/#{elixir}/hex.ez" |
| 12 | ||
| 13 | 1 | :error -> |
| 14 | "installs/hex.ez" | |
| 15 | end | |
| 16 | ||
| 17 | conn | |
| 18 | |> cache([:public, "max-age": 60 * 60], []) | |
| 19 | 9 | |> redirect(external: Hexpm.Utils.cdn_url(url)) |
| 20 | end | |
| 21 | ||
| 22 | defp version_from_user_agent(user_agent) do | |
| 23 | 2 | case List.first(user_agent) do |
| 24 | 1 | "Mix/" <> version -> version |
| 25 | 1 | _ -> "1.0.0" |
| 26 | end | |
| 27 | end | |
| 28 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.LoginController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :nillify_params, ["return"] | |
| 4 | ||
| 5 | def show(conn, _params) do | |
| 6 | 1 | if logged_in?(conn) do |
| 7 | 0 | redirect_return(conn, conn.assigns.current_user, conn.params["return"]) |
| 8 | else | |
| 9 | 1 | render_show(conn) |
| 10 | end | |
| 11 | end | |
| 12 | ||
| 13 | def create(conn, %{"username" => username, "password" => password}) do | |
| 14 | 12 | case password_auth(username, password) do |
| 15 | {:ok, user} -> | |
| 16 | 10 | breached? = Hexpm.Pwned.password_breached?(password) |
| 17 | 10 | login(conn, user, password_breached: breached?) |
| 18 | ||
| 19 | {:error, reason} -> | |
| 20 | conn | |
| 21 | |> put_flash(:error, auth_error_message(reason)) | |
| 22 | |> put_status(400) | |
| 23 | 2 | |> render_show() |
| 24 | end | |
| 25 | end | |
| 26 | ||
| 27 | def delete(conn, _params) do | |
| 28 | conn | |
| 29 | |> delete_session("user_id") | |
| 30 | 1 | |> redirect(to: Routes.page_path(HexpmWeb.Endpoint, :index)) |
| 31 | end | |
| 32 | ||
| 33 | def start_session(conn, user, return) do | |
| 34 | conn | |
| 35 | |> configure_session(renew: true) | |
| 36 | 11 | |> put_session("user_id", user.id) |
| 37 | 11 | |> redirect_return(user, return) |
| 38 | end | |
| 39 | ||
| 40 | defp redirect_return(%{params: %{"hexdocs" => organization}} = conn, user, return) do | |
| 41 | 3 | case generate_hexdocs_key(user, organization) do |
| 42 | {:ok, key} -> | |
| 43 | 2 | docs_url = |
| 44 | Application.get_env(:hexpm, :docs_url) | |
| 45 | 2 | |> String.replace("://", "://#{organization}.") |
| 46 | ||
| 47 | 2 | url = "#{docs_url}#{return}?key=#{key.user_secret}" |
| 48 | 2 | redirect(conn, external: url) |
| 49 | ||
| 50 | {:error, _changeset} -> | |
| 51 | conn | |
| 52 | 1 | |> put_flash(:error, "You don't have access to organization #{organization}") |
| 53 | |> put_status(400) | |
| 54 | 1 | |> render_show() |
| 55 | end | |
| 56 | end | |
| 57 | ||
| 58 | defp redirect_return(conn, user, return) do | |
| 59 | 8 | path = return || Routes.user_path(conn, :show, user) |
| 60 | 8 | redirect(conn, to: path) |
| 61 | end | |
| 62 | ||
| 63 | defp generate_hexdocs_key(user, organization) do | |
| 64 | 3 | Keys.create_for_docs(user, organization) |
| 65 | end | |
| 66 | ||
| 67 | defp render_show(conn) do | |
| 68 | 4 | render( |
| 69 | conn, | |
| 70 | "show.html", | |
| 71 | title: "Log in", | |
| 72 | container: "container page page-xs login", | |
| 73 | 4 | return: conn.params["return"], |
| 74 | 4 | hexdocs: conn.params["hexdocs"] |
| 75 | ) | |
| 76 | end | |
| 77 | ||
| 78 | defp login(conn, %User{id: user_id, tfa: %{tfa_enabled: true, app_enabled: true}}, | |
| 79 | password_breached: breached? | |
| 80 | ) do | |
| 81 | conn | |
| 82 | |> configure_session(renew: true) | |
| 83 | 1 | |> put_session("tfa_user_id", %{uid: user_id, return: conn.params["return"]}) |
| 84 | |> maybe_put_flash(breached?) | |
| 85 | 1 | |> redirect(to: Routes.tfa_auth_path(conn, :show)) |
| 86 | end | |
| 87 | ||
| 88 | defp login(conn, user, password_breached: breached?) do | |
| 89 | conn | |
| 90 | |> maybe_put_flash(breached?) | |
| 91 | 9 | |> start_session(user, conn.params["return"]) |
| 92 | end | |
| 93 | ||
| 94 | 10 | defp maybe_put_flash(conn, false), do: conn |
| 95 | ||
| 96 | defp maybe_put_flash(conn, true) do | |
| 97 | 0 | put_flash(conn, :raw_error, password_breached_message(conn, [])) |
| 98 | end | |
| 99 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.OpenSearchController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | def opensearch(conn, _params) do | |
| 4 | conn | |
| 5 | |> put_resp_content_type("text/xml") | |
| 6 | 1 | |> render("opensearch.xml") |
| 7 | end | |
| 8 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.PackageController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | @packages_per_page 30 | |
| 4 | @audit_logs_per_page 10 | |
| 5 | @sort_params ~w(name recent_downloads total_downloads inserted_at updated_at) | |
| 6 | @letters for letter <- ?A..?Z, do: <<letter>> | |
| 7 | ||
| 8 | def index(conn, params) do | |
| 9 | 8 | letter = Hexpm.Utils.parse_search(params["letter"]) |
| 10 | 8 | search = Hexpm.Utils.parse_search(params["search"]) |
| 11 | ||
| 12 | 8 | filter = |
| 13 | cond do | |
| 14 | 2 | letter -> |
| 15 | {:letter, letter} | |
| 16 | ||
| 17 | 6 | search -> |
| 18 | 4 | search |
| 19 | ||
| 20 | 2 | true -> |
| 21 | nil | |
| 22 | end | |
| 23 | ||
| 24 | 8 | organizations = Users.all_organizations(conn.assigns.current_user) |
| 25 | 8 | repositories = Enum.map(organizations, & &1.repository) |
| 26 | 8 | sort = sort(params["sort"]) |
| 27 | 8 | page_param = Hexpm.Utils.safe_int(params["page"]) || 1 |
| 28 | 8 | package_count = Packages.count(repositories, filter) |
| 29 | 8 | page = Hexpm.Utils.safe_page(page_param, package_count, @packages_per_page) |
| 30 | 8 | packages = fetch_packages(repositories, page, @packages_per_page, filter, sort) |
| 31 | 8 | downloads = Packages.packages_downloads_with_all_views(packages) |
| 32 | 8 | exact_match = exact_match(repositories, search) |
| 33 | ||
| 34 | 8 | render( |
| 35 | conn, | |
| 36 | "index.html", | |
| 37 | title: "Packages", | |
| 38 | container: "container", | |
| 39 | per_page: @packages_per_page, | |
| 40 | search: search, | |
| 41 | letter: letter, | |
| 42 | sort: sort, | |
| 43 | package_count: package_count, | |
| 44 | page: page, | |
| 45 | packages: packages, | |
| 46 | letters: @letters, | |
| 47 | downloads: downloads, | |
| 48 | exact_match: exact_match | |
| 49 | ) | |
| 50 | end | |
| 51 | ||
| 52 | def show(conn, params) do | |
| 53 | # TODO: Show flash if private package and organization does not have active billing | |
| 54 | ||
| 55 | 15 | params = fixup_params(params) |
| 56 | ||
| 57 | 15 | access_package(conn, params, fn package, repositories -> |
| 58 | 9 | releases = Releases.all(package) |
| 59 | ||
| 60 | 9 | {release, type} = |
| 61 | 9 | if version = params["version"] do |
| 62 | {matching_release(releases, version), :release} | |
| 63 | else | |
| 64 | {Release.latest_version(releases, only_stable: true, unstable_fallback: true), :package} | |
| 65 | end | |
| 66 | ||
| 67 | 9 | if release do |
| 68 | 9 | package(conn, repositories, package, releases, release, type) |
| 69 | else | |
| 70 | 0 | not_found(conn) |
| 71 | end | |
| 72 | end) | |
| 73 | end | |
| 74 | ||
| 75 | def audit_logs(conn, params) do | |
| 76 | 3 | access_package(conn, params, fn package, _ -> |
| 77 | 2 | page = Hexpm.Utils.safe_int(params["page"]) || 1 |
| 78 | 2 | per_page = 100 |
| 79 | 2 | audit_logs = AuditLogs.all_by(package, page, per_page) |
| 80 | 2 | total_count = AuditLogs.count_by(package) |
| 81 | ||
| 82 | 2 | render(conn, "audit_logs.html", |
| 83 | 2 | title: "Recent Activities for #{package.name}", |
| 84 | container: "container package-view", | |
| 85 | package: package, | |
| 86 | audit_logs: audit_logs, | |
| 87 | page: page, | |
| 88 | per_page: per_page, | |
| 89 | total_count: total_count | |
| 90 | ) | |
| 91 | end) | |
| 92 | end | |
| 93 | ||
| 94 | defp access_package(conn, params, fun) do | |
| 95 | 18 | %{"repository" => repository, "name" => name} = params |
| 96 | 18 | organizations = Users.all_organizations(conn.assigns.current_user) |
| 97 | 18 | repositories = Map.new(organizations, &{&1.repository.name, &1.repository}) |
| 98 | ||
| 99 | 4 | if repository = repositories[repository] do |
| 100 | 14 | package = repository && Packages.get(repository, name) |
| 101 | ||
| 102 | # Should have access even though organization does not have active billing | |
| 103 | 14 | if package do |
| 104 | 11 | fun.(package, Enum.map(organizations, & &1.repository)) |
| 105 | end | |
| 106 | 18 | end || not_found(conn) |
| 107 | end | |
| 108 | ||
| 109 | 8 | defp sort(nil), do: sort("recent_downloads") |
| 110 | 0 | defp sort("downloads"), do: sort("recent_downloads") |
| 111 | 8 | defp sort(param), do: Hexpm.Utils.safe_to_atom(param, @sort_params) |
| 112 | ||
| 113 | defp matching_release(releases, version) do | |
| 114 | 4 | Enum.find(releases, &(to_string(&1.version) == version)) |
| 115 | end | |
| 116 | ||
| 117 | defp package(conn, repositories, package, releases, release, type) do | |
| 118 | 9 | repository = package.repository |
| 119 | 9 | release = Releases.preload(release, [:requirements, :downloads, :publisher]) |
| 120 | ||
| 121 | 9 | latest_release_with_docs = |
| 122 | Release.latest_version(releases, only_stable: true, unstable_fallback: true, with_docs: true) | |
| 123 | ||
| 124 | 9 | docs_assigns = |
| 125 | cond do | |
| 126 | 9 | type == :package && latest_release_with_docs -> |
| 127 | [ | |
| 128 | docs_html_url: Hexpm.Utils.docs_html_url(repository, package, nil), | |
| 129 | docs_tarball_url: | |
| 130 | Hexpm.Utils.docs_tarball_url(repository, package, latest_release_with_docs) | |
| 131 | ] | |
| 132 | ||
| 133 | 4 | type == :release and release.has_docs -> |
| 134 | [ | |
| 135 | docs_html_url: Hexpm.Utils.docs_html_url(repository, package, release), | |
| 136 | docs_tarball_url: Hexpm.Utils.docs_tarball_url(repository, package, release) | |
| 137 | ] | |
| 138 | ||
| 139 | 2 | true -> |
| 140 | [docs_html_url: nil, docs_tarball_url: nil] | |
| 141 | end | |
| 142 | ||
| 143 | 9 | downloads = Packages.package_downloads(package) |
| 144 | ||
| 145 | 9 | graph_downloads = |
| 146 | case type do | |
| 147 | 5 | :package -> Packages.downloads_for_last_n_days(package.id, 31) |
| 148 | 4 | :release -> Releases.downloads_for_last_n_days(release.id, 31) |
| 149 | end | |
| 150 | ||
| 151 | 9 | daily_graph = |
| 152 | Date.utc_today() | |
| 153 | |> Date.add(-31) | |
| 154 | |> Date.range(Date.add(Date.utc_today(), -1)) | |
| 155 | |> Enum.map(fn date -> | |
| 156 | 279 | Enum.find(graph_downloads, fn dl -> date == Date.from_iso8601!(dl.day) end) |
| 157 | end) | |
| 158 | |> Enum.map(fn | |
| 159 | 279 | nil -> 0 |
| 160 | 0 | %{downloads: dl} -> dl |
| 161 | end) | |
| 162 | ||
| 163 | 9 | owners = Owners.all(package, user: [:emails, :organization]) |
| 164 | ||
| 165 | 9 | dependants = |
| 166 | Packages.search( | |
| 167 | repositories, | |
| 168 | 1, | |
| 169 | 20, | |
| 170 | 9 | "depends:#{repository.name}:#{package.name}", |
| 171 | :recent_downloads, | |
| 172 | [:name, :repository_id] | |
| 173 | ) | |
| 174 | ||
| 175 | 9 | dependants_count = Packages.count(repositories, "depends:#{repository.name}:#{package.name}") |
| 176 | ||
| 177 | 9 | audit_logs = AuditLogs.all_by(package, 1, @audit_logs_per_page) |
| 178 | ||
| 179 | 9 | render( |
| 180 | conn, | |
| 181 | "show.html", | |
| 182 | [ | |
| 183 | 9 | title: package.name, |
| 184 | 9 | description: package.meta.description, |
| 185 | container: "container package-view", | |
| 186 | canonical_url: Routes.package_url(conn, :show, package), | |
| 187 | package: package, | |
| 188 | 9 | repository_name: repository.name, |
| 189 | releases: releases, | |
| 190 | current_release: release, | |
| 191 | downloads: downloads, | |
| 192 | owners: owners, | |
| 193 | dependants: dependants, | |
| 194 | dependants_count: dependants_count, | |
| 195 | audit_logs: audit_logs, | |
| 196 | daily_graph: daily_graph, | |
| 197 | type: type | |
| 198 | ] ++ docs_assigns | |
| 199 | ) | |
| 200 | end | |
| 201 | ||
| 202 | defp fetch_packages(repositories, page, packages_per_page, search, sort) do | |
| 203 | 8 | packages = Packages.search(repositories, page, packages_per_page, search, sort, nil) |
| 204 | 8 | Packages.attach_versions(packages) |
| 205 | end | |
| 206 | ||
| 207 | 4 | defp exact_match(_organizations, nil) do |
| 208 | nil | |
| 209 | end | |
| 210 | ||
| 211 | defp exact_match(repositories, search) do | |
| 212 | search | |
| 213 | |> String.replace(" ", "_") | |
| 214 | |> String.split("/", parts: 2) | |
| 215 | 4 | |> case do |
| 216 | [repository, package] -> | |
| 217 | 0 | if repository in Enum.map(repositories, & &1.name) do |
| 218 | 0 | Packages.get(repository, package) |
| 219 | end | |
| 220 | ||
| 221 | [term] -> | |
| 222 | 4 | try do |
| 223 | 4 | Packages.get(repositories, term) |
| 224 | rescue | |
| 225 | 0 | Ecto.MultipleResultsError -> |
| 226 | nil | |
| 227 | end | |
| 228 | end | |
| 229 | end | |
| 230 | ||
| 231 | defp fixup_params(%{"name" => name, "version" => version} = params) do | |
| 232 | 10 | case Version.parse(version) do |
| 233 | {:ok, _} -> | |
| 234 | 7 | params |
| 235 | ||
| 236 | :error -> | |
| 237 | params | |
| 238 | |> Map.put("repository", name) | |
| 239 | |> Map.put("name", version) | |
| 240 | 3 | |> Map.delete("version") |
| 241 | end | |
| 242 | end | |
| 243 | ||
| 244 | defp fixup_params(params) do | |
| 245 | 5 | params |
| 246 | end | |
| 247 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.PackageReportController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :requires_login | |
| 4 | ||
| 5 | @new_report_msg "Package report generated" | |
| 6 | @report_updated_msg "Package report updated" | |
| 7 | @report_bad_update_msg "Package report can not be updated" | |
| 8 | @report_bad_version_msg "No release matches given requirement" | |
| 9 | ||
| 10 | def new_comment(conn, params) do | |
| 11 | 0 | report = PackageReports.get(params["id"]) |
| 12 | 0 | author = conn.assigns.current_user |
| 13 | 0 | PackageReports.new_comment(report, author, params) |
| 14 | ||
| 15 | 0 | redirect(conn, to: Routes.package_report_path(HexpmWeb.Endpoint, :show, report.id)) |
| 16 | end | |
| 17 | ||
| 18 | def index(conn, _params) do | |
| 19 | 1 | reports = PackageReports.all() |
| 20 | 1 | reports_count = Enum.count(reports) |
| 21 | ||
| 22 | 1 | render( |
| 23 | conn, | |
| 24 | "index.html", | |
| 25 | reports: reports, | |
| 26 | total: reports_count | |
| 27 | ) | |
| 28 | end | |
| 29 | ||
| 30 | def new(conn, params) do | |
| 31 | 0 | package = params["package"] |
| 32 | ||
| 33 | 0 | if package do |
| 34 | 0 | build_report_form(conn, params) |
| 35 | else | |
| 36 | 0 | not_found(conn) |
| 37 | end | |
| 38 | end | |
| 39 | ||
| 40 | def create(conn, params) do | |
| 41 | 0 | description = params["description"] |
| 42 | 0 | package_name = params["package"] |
| 43 | 0 | state = "to_accept" |
| 44 | 0 | requirement = params["requirement"] |
| 45 | 0 | repository = params["repository"] |
| 46 | ||
| 47 | 0 | package = Packages.get(repository, package_name) |
| 48 | ||
| 49 | 0 | user = conn.assigns.current_user |
| 50 | 0 | all_releases = Releases.all(package) |
| 51 | ||
| 52 | 0 | report_releases = slice_releases(all_releases, requirement) |
| 53 | ||
| 54 | 0 | if report_releases == [] do |
| 55 | conn | |
| 56 | |> put_flash(:error, @report_bad_version_msg) | |
| 57 | |> put_status(400) | |
| 58 | 0 | |> new(%{ |
| 59 | "repository" => repository, | |
| 60 | "package" => package_name, | |
| 61 | "description" => description | |
| 62 | }) | |
| 63 | else | |
| 64 | %{ | |
| 65 | "package" => package, | |
| 66 | "releases" => report_releases, | |
| 67 | "user" => user, | |
| 68 | "description" => description, | |
| 69 | "state" => state | |
| 70 | } | |
| 71 | 0 | |> PackageReports.add() |
| 72 | ||
| 73 | conn | |
| 74 | |> put_flash(:info, @new_report_msg) | |
| 75 | 0 | |> redirect(to: Routes.package_report_path(HexpmWeb.Endpoint, :index)) |
| 76 | end | |
| 77 | end | |
| 78 | ||
| 79 | def show(conn, params) do | |
| 80 | 21 | report = PackageReports.get(params["id"]) |
| 81 | 21 | user = conn.assigns.current_user |
| 82 | ||
| 83 | 21 | if report do |
| 84 | 20 | for_moderator = User.has_role?(user, "moderator") |
| 85 | 20 | for_owner = Owners.get(report.package, user) != nil |
| 86 | 20 | for_author = user.id == report.author.id |
| 87 | ||
| 88 | 20 | if visible_report?(report, user, for_owner) do |
| 89 | 15 | comments = PackageReports.all_comments(report.id) |
| 90 | ||
| 91 | 15 | render( |
| 92 | conn, | |
| 93 | "show.html", | |
| 94 | report: report, | |
| 95 | for_moderator: for_moderator, | |
| 96 | for_owner: for_owner, | |
| 97 | for_author: for_author, | |
| 98 | comments: comments | |
| 99 | ) | |
| 100 | else | |
| 101 | 5 | not_found(conn) |
| 102 | end | |
| 103 | else | |
| 104 | 1 | not_found(conn) |
| 105 | end | |
| 106 | end | |
| 107 | ||
| 108 | defp visible_report?(report, user, owner?) do | |
| 109 | 20 | moderator? = User.has_role?(user, "moderator") |
| 110 | 20 | author? = user.id == report.author.id |
| 111 | ||
| 112 | 20 | cond do |
| 113 | 20 | report.state in ["to_accept", "rejected"] -> moderator? or author? |
| 114 | 12 | report.state == "accepted" -> moderator? or author? or owner? |
| 115 | 8 | report.state in ["solved", "unresolved"] -> true |
| 116 | end | |
| 117 | end | |
| 118 | ||
| 119 | def accept_report(conn, params) do | |
| 120 | 0 | report_id = params["id"] |
| 121 | ||
| 122 | 0 | report = PackageReports.get(report_id) |
| 123 | ||
| 124 | 0 | if valid_state_change?("accepted", report) and |
| 125 | 0 | User.has_role?(conn.assigns.current_user, "moderator") do |
| 126 | 0 | PackageReports.accept(report_id) |
| 127 | 0 | notify_good_update(conn) |
| 128 | else | |
| 129 | 0 | notify_bad_update(conn, %{"id" => report_id}) |
| 130 | end | |
| 131 | end | |
| 132 | ||
| 133 | def reject_report(conn, params) do | |
| 134 | 0 | report_id = params["id"] |
| 135 | ||
| 136 | 0 | report = PackageReports.get(report_id) |
| 137 | ||
| 138 | 0 | if valid_state_change?("rejected", report) and |
| 139 | 0 | User.has_role?(conn.assigns.current_user, "moderator") do |
| 140 | 0 | PackageReports.reject(report_id) |
| 141 | ||
| 142 | 0 | notify_good_update(conn) |
| 143 | else | |
| 144 | 0 | notify_bad_update(conn, %{"id" => report_id}) |
| 145 | end | |
| 146 | end | |
| 147 | ||
| 148 | def solve_report(conn, params) do | |
| 149 | 1 | report_id = params["id"] |
| 150 | ||
| 151 | 1 | report = PackageReports.get(report_id) |
| 152 | ||
| 153 | 1 | if valid_state_change?("solved", report) and |
| 154 | 1 | User.has_role?(conn.assigns.current_user, "moderator") do |
| 155 | 1 | PackageReports.solve(report_id) |
| 156 | ||
| 157 | 1 | notify_good_update(conn) |
| 158 | else | |
| 159 | 0 | notify_bad_update(conn, %{"id" => report_id}) |
| 160 | end | |
| 161 | end | |
| 162 | ||
| 163 | def unresolve_report(conn, params) do | |
| 164 | 0 | report_id = params["id"] |
| 165 | ||
| 166 | 0 | report = PackageReports.get(report_id) |
| 167 | ||
| 168 | 0 | if valid_state_change?("unresolved", report) and |
| 169 | 0 | User.has_role?(conn.assigns.current_user, "moderator") do |
| 170 | 0 | PackageReports.unresolve(report_id) |
| 171 | ||
| 172 | 0 | notify_good_update(conn) |
| 173 | else | |
| 174 | 0 | notify_bad_update(conn, %{"id" => report_id}) |
| 175 | end | |
| 176 | end | |
| 177 | ||
| 178 | defp notify_good_update(conn) do | |
| 179 | conn | |
| 180 | |> put_flash(:info, @report_updated_msg) | |
| 181 | 1 | |> redirect(to: Routes.page_path(HexpmWeb.Endpoint, :index)) |
| 182 | end | |
| 183 | ||
| 184 | defp notify_bad_update(conn, params) do | |
| 185 | conn | |
| 186 | |> put_flash(:error, @report_bad_update_msg) | |
| 187 | |> put_status(400) | |
| 188 | 0 | |> show(params) |
| 189 | end | |
| 190 | ||
| 191 | 0 | defp valid_state_change?(new, %{state: "to_accept"}), do: new in ["accepted", "rejected"] |
| 192 | ||
| 193 | defp valid_state_change?(new, %{state: "accepted"}), | |
| 194 | 1 | do: new in ["solved", "rejected", "unresolved"] |
| 195 | ||
| 196 | 0 | defp valid_state_change?(new, %{state: "rejected"}), do: new in ["accepted"] |
| 197 | 0 | defp valid_state_change?(_new, _), do: false |
| 198 | ||
| 199 | defp slice_releases(releases, requirement) do | |
| 200 | 0 | case Version.parse_requirement(requirement) do |
| 201 | {:ok, requirement} -> | |
| 202 | 0 | Enum.filter(releases, &Version.match?(&1.version, requirement)) |
| 203 | ||
| 204 | 0 | :error -> |
| 205 | [] | |
| 206 | end | |
| 207 | end | |
| 208 | ||
| 209 | defp build_report_form(conn, params) do | |
| 210 | 0 | %{"repository" => repository, "package" => name} = params |
| 211 | 0 | description = params["description"] |
| 212 | ||
| 213 | 0 | render( |
| 214 | conn, | |
| 215 | "new_report.html", | |
| 216 | package_name: name, | |
| 217 | repository: repository, | |
| 218 | description: description | |
| 219 | ) | |
| 220 | end | |
| 221 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.PageController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | def index(conn, _params) do | |
| 4 | 2 | hexpm = Repository.hexpm() |
| 5 | ||
| 6 | 2 | render( |
| 7 | conn, | |
| 8 | "index.html", | |
| 9 | container: "", | |
| 10 | custom_flash: true, | |
| 11 | hide_search: true, | |
| 12 | num_packages: Packages.count(), | |
| 13 | num_releases: Releases.count(), | |
| 14 | package_top: Packages.top_downloads(hexpm, "recent", 8), | |
| 15 | package_new: Packages.recent(hexpm, 10), | |
| 16 | releases_new: Releases.recent(hexpm, 10), | |
| 17 | total: Packages.total_downloads() | |
| 18 | ) | |
| 19 | end | |
| 20 | ||
| 21 | def about(conn, _params) do | |
| 22 | 1 | render( |
| 23 | conn, | |
| 24 | "about.html", | |
| 25 | title: "About Hex", | |
| 26 | container: "container page page-sm" | |
| 27 | ) | |
| 28 | end | |
| 29 | ||
| 30 | def pricing(conn, _params) do | |
| 31 | 1 | render( |
| 32 | conn, | |
| 33 | "pricing.html", | |
| 34 | title: "Pricing", | |
| 35 | container: "container page pricing" | |
| 36 | ) | |
| 37 | end | |
| 38 | ||
| 39 | def sponsors(conn, _params) do | |
| 40 | 1 | render( |
| 41 | conn, | |
| 42 | "sponsors.html", | |
| 43 | title: "Sponsors", | |
| 44 | container: "container page page-sm sponsors" | |
| 45 | ) | |
| 46 | end | |
| 47 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.PasswordController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | def show(conn, %{"username" => username, "key" => key}) do | |
| 4 | conn | |
| 5 | |> put_session("reset_username", username) | |
| 6 | |> put_session("reset_key", key) | |
| 7 | 1 | |> redirect(to: Routes.password_path(conn, :show)) |
| 8 | end | |
| 9 | ||
| 10 | def show(conn, _params) do | |
| 11 | 1 | username = get_session(conn, "reset_username") |
| 12 | 1 | key = get_session(conn, "reset_key") |
| 13 | ||
| 14 | 1 | if username && key do |
| 15 | 1 | changeset = User.update_password(%User{}, %{}) |
| 16 | ||
| 17 | conn | |
| 18 | |> delete_session("reset_username") | |
| 19 | |> delete_session("reset_key") | |
| 20 | 1 | |> render_show(username, key, changeset) |
| 21 | else | |
| 22 | conn | |
| 23 | |> put_flash(:error, "Invalid password reset key.") | |
| 24 | 0 | |> redirect(to: Routes.page_path(HexpmWeb.Endpoint, :index)) |
| 25 | end | |
| 26 | end | |
| 27 | ||
| 28 | def update(conn, params) do | |
| 29 | 3 | params = params["user"] |
| 30 | 3 | username = params["username"] |
| 31 | 3 | key = params["key"] |
| 32 | 3 | revoke_all_keys? = (params["revoke_all_keys"] || "yes") == "yes" |
| 33 | ||
| 34 | 3 | case Users.password_reset_finish( |
| 35 | username, | |
| 36 | key, | |
| 37 | params, | |
| 38 | revoke_all_keys?, | |
| 39 | audit: audit_data(conn) | |
| 40 | ) do | |
| 41 | :ok -> | |
| 42 | 1 | breached? = Hexpm.Pwned.password_breached?(params["password"]) |
| 43 | ||
| 44 | conn | |
| 45 | |> clear_session() | |
| 46 | |> configure_session(renew: true) | |
| 47 | |> maybe_put_flash(breached?) | |
| 48 | |> put_flash(:info, "Your account password has been changed to your new password.") | |
| 49 | 1 | |> redirect(to: Routes.page_path(HexpmWeb.Endpoint, :index)) |
| 50 | ||
| 51 | :error -> | |
| 52 | conn | |
| 53 | |> put_flash(:error, "Failed to change your password.") | |
| 54 | 2 | |> redirect(to: Routes.page_path(HexpmWeb.Endpoint, :index)) |
| 55 | ||
| 56 | {:error, changeset} -> | |
| 57 | conn | |
| 58 | |> put_status(400) | |
| 59 | 0 | |> render_show(username, key, changeset) |
| 60 | end | |
| 61 | end | |
| 62 | ||
| 63 | defp render_show(conn, username, key, changeset) do | |
| 64 | 1 | render( |
| 65 | conn, | |
| 66 | "show.html", | |
| 67 | title: "Choose a new password", | |
| 68 | container: "container page page-xs password-view", | |
| 69 | username: username, | |
| 70 | key: key, | |
| 71 | changeset: changeset | |
| 72 | ) | |
| 73 | end | |
| 74 | ||
| 75 | 1 | defp maybe_put_flash(conn, false), do: conn |
| 76 | ||
| 77 | defp maybe_put_flash(conn, true) do | |
| 78 | 0 | put_flash(conn, :raw_error, password_breached_message(conn, [])) |
| 79 | end | |
| 80 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.PasswordResetController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | def show(conn, _params) do | |
| 4 | 1 | render( |
| 5 | conn, | |
| 6 | "show.html", | |
| 7 | title: "Reset your password", | |
| 8 | container: "container page page-xs password-view" | |
| 9 | ) | |
| 10 | end | |
| 11 | ||
| 12 | def create(conn, %{"username" => name}) do | |
| 13 | 2 | Users.password_reset_init(name, audit: audit_data(conn)) |
| 14 | ||
| 15 | 2 | render( |
| 16 | conn, | |
| 17 | "create.html", | |
| 18 | title: "Reset your password", | |
| 19 | container: "container page page-xs password-view" | |
| 20 | ) | |
| 21 | end | |
| 22 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.PolicyController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | def coc(conn, _params) do | |
| 4 | 1 | render( |
| 5 | conn, | |
| 6 | "coc.html", | |
| 7 | title: "Code of Conduct", | |
| 8 | container: "container page page-sm policies" | |
| 9 | ) | |
| 10 | end | |
| 11 | ||
| 12 | def copyright(conn, _params) do | |
| 13 | 1 | render( |
| 14 | conn, | |
| 15 | "copyright.html", | |
| 16 | title: "Copyright Policy", | |
| 17 | container: "container page page-sm policies" | |
| 18 | ) | |
| 19 | end | |
| 20 | ||
| 21 | def privacy(conn, _params) do | |
| 22 | 1 | render( |
| 23 | conn, | |
| 24 | "privacy.html", | |
| 25 | title: "Privacy Policy", | |
| 26 | container: "container page page-sm policies" | |
| 27 | ) | |
| 28 | end | |
| 29 | ||
| 30 | def tos(conn, _params) do | |
| 31 | 1 | render( |
| 32 | conn, | |
| 33 | "tos.html", | |
| 34 | title: "Terms of Service", | |
| 35 | container: "container page page-sm policies" | |
| 36 | ) | |
| 37 | end | |
| 38 | ||
| 39 | def dispute(conn, _params) do | |
| 40 | 0 | render( |
| 41 | conn, | |
| 42 | "dispute.html", | |
| 43 | title: "Dispute policy", | |
| 44 | container: "container page page-sm policies" | |
| 45 | ) | |
| 46 | end | |
| 47 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.ShortURLController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | alias Hexpm.ShortURLs | |
| 3 | alias Hexpm.ShortURLs.ShortURL | |
| 4 | ||
| 5 | def show(conn, %{"short_code" => short_code}) do | |
| 6 | 2 | case ShortURLs.get(short_code) do |
| 7 | nil -> | |
| 8 | 1 | not_found(conn) |
| 9 | ||
| 10 | %ShortURL{url: url} -> | |
| 11 | conn | |
| 12 | |> put_status(301) | |
| 13 | 1 | |> redirect(external: url) |
| 14 | end | |
| 15 | end | |
| 16 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.SignupController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | def show(conn, _params) do | |
| 4 | 1 | if logged_in?(conn) do |
| 5 | 0 | path = Routes.user_path(conn, :show, conn.assigns.current_user) |
| 6 | 0 | redirect(conn, to: path) |
| 7 | else | |
| 8 | 1 | render_show(conn, User.build(%{})) |
| 9 | end | |
| 10 | end | |
| 11 | ||
| 12 | def create(conn, params) do | |
| 13 | 2 | case Users.add(params["user"], audit: audit_data(conn)) do |
| 14 | {:ok, _user} -> | |
| 15 | 1 | flash = |
| 16 | "A confirmation email has been sent, " <> | |
| 17 | "you will have access to your account shortly." | |
| 18 | ||
| 19 | conn | |
| 20 | |> put_flash(:info, flash) | |
| 21 | 1 | |> redirect(to: Routes.page_path(HexpmWeb.Endpoint, :index)) |
| 22 | ||
| 23 | {:error, changeset} -> | |
| 24 | conn | |
| 25 | |> put_status(400) | |
| 26 | 1 | |> render_show(changeset) |
| 27 | end | |
| 28 | end | |
| 29 | ||
| 30 | defp render_show(conn, changeset) do | |
| 31 | 2 | render( |
| 32 | conn, | |
| 33 | "show.html", | |
| 34 | title: "Sign up", | |
| 35 | container: "container page page-xs signup", | |
| 36 | changeset: changeset | |
| 37 | ) | |
| 38 | end | |
| 39 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.SitemapController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | def main(conn, _params) do | |
| 4 | conn | |
| 5 | |> put_resp_content_type("text/xml") | |
| 6 | |> put_resp_header("cache-control", "public, max-age=300") | |
| 7 | 1 | |> render("packages_sitemap.xml", packages: Sitemaps.packages()) |
| 8 | end | |
| 9 | ||
| 10 | def docs(conn, _params) do | |
| 11 | conn | |
| 12 | |> put_resp_content_type("text/xml") | |
| 13 | |> put_resp_header("cache-control", "public, max-age=300") | |
| 14 | 1 | |> render("docs_sitemap.xml", packages: Sitemaps.packages_with_docs()) |
| 15 | end | |
| 16 | ||
| 17 | def preview(conn, _params) do | |
| 18 | conn | |
| 19 | |> put_resp_content_type("text/xml") | |
| 20 | |> put_resp_header("cache-control", "public, max-age=300") | |
| 21 | 1 | |> render("preview_sitemap.xml", packages: Sitemaps.packages_for_preview()) |
| 22 | end | |
| 23 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.TestController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | def names(conn, _params) do | |
| 4 | Hexpm.Store.get(:repo_bucket, "names", []) | |
| 5 | 0 | |> send_object(conn) |
| 6 | end | |
| 7 | ||
| 8 | def versions(conn, _params) do | |
| 9 | Hexpm.Store.get(:repo_bucket, "versions", []) | |
| 10 | 0 | |> send_object(conn) |
| 11 | end | |
| 12 | ||
| 13 | def package(conn, %{"repository" => repository, "package" => package}) do | |
| 14 | 0 | Hexpm.Store.get(:repo_bucket, "repos/#{repository}/packages/#{package}", []) |
| 15 | 0 | |> send_object(conn) |
| 16 | end | |
| 17 | ||
| 18 | def package(conn, %{"package" => package}) do | |
| 19 | 0 | Hexpm.Store.get(:repo_bucket, "packages/#{package}", []) |
| 20 | 0 | |> send_object(conn) |
| 21 | end | |
| 22 | ||
| 23 | def tarball(conn, %{"repository" => repository, "ball" => ball}) do | |
| 24 | 0 | Hexpm.Store.get(:repo_bucket, "repos/#{repository}/tarballs/#{ball}", []) |
| 25 | 0 | |> send_object(conn) |
| 26 | end | |
| 27 | ||
| 28 | def tarball(conn, %{"ball" => ball}) do | |
| 29 | 0 | Hexpm.Store.get(:repo_bucket, "tarballs/#{ball}", []) |
| 30 | 0 | |> send_object(conn) |
| 31 | end | |
| 32 | ||
| 33 | def repo(conn, params) do | |
| 34 | 0 | {:ok, organization} = |
| 35 | 0 | Organizations.create(conn.assigns.current_user, params, |
| 36 | audit: {%User{}, "TEST", "127.0.0.1"} | |
| 37 | ) | |
| 38 | ||
| 39 | organization | |
| 40 | |> Ecto.Changeset.change(%{billing_active: true}) | |
| 41 | 0 | |> Hexpm.Repo.update!() |
| 42 | ||
| 43 | 0 | send_resp(conn, 204, "") |
| 44 | end | |
| 45 | ||
| 46 | def installs_csv(conn, _params) do | |
| 47 | 0 | send_resp(conn, 200, "") |
| 48 | end | |
| 49 | ||
| 50 | 0 | defp send_object(nil, conn), do: send_resp(conn, 404, "") |
| 51 | 0 | defp send_object(obj, conn), do: send_resp(conn, 200, obj) |
| 52 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.TFAAuthController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :authenticate | |
| 4 | ||
| 5 | 1 | def show(conn, _params), do: render_show(conn) |
| 6 | ||
| 7 | def create(conn, %{"code" => code}) do | |
| 8 | 2 | %{"uid" => uid} = session = get_session(conn, "tfa_user_id") |
| 9 | 2 | user = Hexpm.Accounts.Users.get_by_id(uid) |
| 10 | 2 | secret = user.tfa.secret |
| 11 | ||
| 12 | 2 | if Hexpm.Accounts.TFA.token_valid?(secret, code) do |
| 13 | conn | |
| 14 | |> delete_session("tfa_user_id") | |
| 15 | 1 | |> HexpmWeb.LoginController.start_session(user, session["return"]) |
| 16 | else | |
| 17 | 1 | render_show_error(conn) |
| 18 | end | |
| 19 | end | |
| 20 | ||
| 21 | defp render_show(conn) do | |
| 22 | 2 | render( |
| 23 | conn, | |
| 24 | "show.html", | |
| 25 | title: "Two Factor Authentication", | |
| 26 | container: "container page page-xs login" | |
| 27 | ) | |
| 28 | end | |
| 29 | ||
| 30 | defp render_show_error(conn) do | |
| 31 | 1 | msg = "The verification code you provided is incorrect. Please try again." |
| 32 | ||
| 33 | conn | |
| 34 | |> put_flash(:error, msg) | |
| 35 | 1 | |> render_show() |
| 36 | end | |
| 37 | ||
| 38 | defp authenticate(conn, _opts) do | |
| 39 | 4 | case get_session(conn, "tfa_user_id") do |
| 40 | nil -> | |
| 41 | 1 | conn |> redirect(to: "/") |> halt() |
| 42 | ||
| 43 | _ -> | |
| 44 | 3 | conn |
| 45 | end | |
| 46 | end | |
| 47 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.TFARecoveryController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | plug :authenticate | |
| 4 | ||
| 5 | 1 | def show(conn, _params), do: render_show(conn) |
| 6 | ||
| 7 | def create(conn, %{"code" => code}) do | |
| 8 | 2 | %{"uid" => uid} = session = get_session(conn, "tfa_user_id") |
| 9 | 2 | user = Hexpm.Accounts.Users.get_by_id(uid) |
| 10 | ||
| 11 | 2 | with true <- valid_code?(code), |
| 12 | 1 | {:ok, updated_user} <- Hexpm.Accounts.Users.tfa_recover(user, code) do |
| 13 | conn | |
| 14 | |> delete_session("tfa_user_id") | |
| 15 | 1 | |> HexpmWeb.LoginController.start_session(updated_user, session["return"]) |
| 16 | else | |
| 17 | _ -> | |
| 18 | 1 | render_show_error(conn) |
| 19 | end | |
| 20 | end | |
| 21 | ||
| 22 | defp render_show(conn) do | |
| 23 | 2 | render( |
| 24 | conn, | |
| 25 | "show.html", | |
| 26 | title: "Two Factor Recovery", | |
| 27 | container: "container page page-xs login" | |
| 28 | ) | |
| 29 | end | |
| 30 | ||
| 31 | defp render_show_error(conn) do | |
| 32 | 1 | msg = "The recovery code you provided is incorrect. Please try again." |
| 33 | ||
| 34 | conn | |
| 35 | |> put_flash(:error, msg) | |
| 36 | 1 | |> render_show() |
| 37 | end | |
| 38 | ||
| 39 | defp authenticate(conn, _opts) do | |
| 40 | 3 | case get_session(conn, "tfa_user_id") do |
| 41 | nil -> | |
| 42 | 0 | conn |> redirect(to: "/") |> halt() |
| 43 | ||
| 44 | _ -> | |
| 45 | 3 | conn |
| 46 | end | |
| 47 | end | |
| 48 | ||
| 49 | 2 | defp valid_code?(code), do: is_binary(code) and byte_size(code) == 19 |
| 50 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.UserController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | def show(conn, %{"username" => username}) do | |
| 4 | 4 | user = |
| 5 | Users.get_by_username(username, [ | |
| 6 | :emails, | |
| 7 | :organization, | |
| 8 | owned_packages: [:repository, :downloads] | |
| 9 | ]) | |
| 10 | ||
| 11 | 4 | if user do |
| 12 | 4 | organization = user.organization |
| 13 | ||
| 14 | 4 | case conn.path_info do |
| 15 | ["users" | _] when not is_nil(organization) -> | |
| 16 | 0 | redirect(conn, to: Router.user_path(user)) |
| 17 | ||
| 18 | ["orgs" | _] when is_nil(organization) -> | |
| 19 | 0 | redirect(conn, to: Router.user_path(user)) |
| 20 | ||
| 21 | _ -> | |
| 22 | 4 | show_user(conn, user) |
| 23 | end | |
| 24 | else | |
| 25 | 0 | not_found(conn) |
| 26 | end | |
| 27 | end | |
| 28 | ||
| 29 | defp show_user(conn, user) do | |
| 30 | 4 | packages = |
| 31 | 4 | Packages.accessible_user_owned_packages(user, conn.assigns.current_user) |
| 32 | |> Packages.attach_versions() | |
| 33 | ||
| 34 | 4 | downloads = Packages.packages_downloads_with_all_views(packages) |
| 35 | ||
| 36 | 4 | total_downloads = |
| 37 | 0 | Enum.reduce(downloads, 0, fn {_id, d}, acc -> acc + Map.get(d, "all", 0) end) |
| 38 | ||
| 39 | 4 | public_email = User.email(user, :public) |
| 40 | 4 | gravatar_email = User.email(user, :gravatar) |
| 41 | ||
| 42 | 4 | render( |
| 43 | conn, | |
| 44 | "show.html", | |
| 45 | 4 | title: user.username, |
| 46 | container: "container page user", | |
| 47 | user: user, | |
| 48 | packages: packages, | |
| 49 | downloads: downloads, | |
| 50 | total_downloads: total_downloads, | |
| 51 | public_email: public_email, | |
| 52 | gravatar_email: gravatar_email | |
| 53 | ) | |
| 54 | end | |
| 55 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.VersionController do | |
| 1 | use HexpmWeb, :controller | |
| 2 | ||
| 3 | def index(conn, params) do | |
| 4 | 2 | %{"repository" => repository, "name" => name} = params |
| 5 | 2 | organizations = Users.all_organizations(conn.assigns.current_user) |
| 6 | 2 | repositories = Enum.map(organizations, & &1.repository) |
| 7 | ||
| 8 | 3 | if repository in Enum.map(repositories, & &1.name) do |
| 9 | 2 | repository = Repositories.get(repository) |
| 10 | 2 | package = repository && Packages.get(repository, name) |
| 11 | ||
| 12 | # Should have access even though repository does not have active billing | |
| 13 | 2 | if package do |
| 14 | 2 | releases = Releases.all(package) |
| 15 | ||
| 16 | 2 | if releases do |
| 17 | 2 | render( |
| 18 | conn, | |
| 19 | "index.html", | |
| 20 | 2 | title: "#{name} versions", |
| 21 | container: "container", | |
| 22 | releases: releases, | |
| 23 | package: package | |
| 24 | ) | |
| 25 | end | |
| 26 | end | |
| 27 | 2 | end || not_found(conn) |
| 28 | end | |
| 29 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.ElixirFormat do | |
| 1 | def encode_to_iodata!(term) do | |
| 2 | term | |
| 3 | |> Hexpm.Utils.binarify() | |
| 4 | 0 | |> inspect(limit: :infinity, binaries: :as_strings) |
| 5 | end | |
| 6 | ||
| 7 | @spec decode(String.t()) :: term | |
| 8 | 0 | def decode("") do |
| 9 | {:ok, nil} | |
| 10 | end | |
| 11 | ||
| 12 | def decode(string) do | |
| 13 | 0 | case Code.string_to_quoted(string, existing_atoms_only: true) do |
| 14 | {:ok, ast} -> | |
| 15 | 0 | safe_eval(ast) |
| 16 | ||
| 17 | 0 | _ -> |
| 18 | {:error, "malformed elixir"} | |
| 19 | end | |
| 20 | end | |
| 21 | ||
| 22 | defp safe_eval(ast) do | |
| 23 | 0 | if safe_term?(ast) do |
| 24 | 0 | result = Code.eval_quoted(ast) |> elem(0) |
| 25 | {:ok, result} | |
| 26 | else | |
| 27 | {:error, "unsafe elixir"} | |
| 28 | end | |
| 29 | end | |
| 30 | ||
| 31 | defp safe_term?({func, _, terms}) when func in [:{}, :%{}] and is_list(terms) do | |
| 32 | 0 | Enum.all?(terms, &safe_term?/1) |
| 33 | end | |
| 34 | ||
| 35 | 0 | defp safe_term?(nil), do: true |
| 36 | 0 | defp safe_term?(term) when is_number(term), do: true |
| 37 | 0 | defp safe_term?(term) when is_binary(term), do: true |
| 38 | 0 | defp safe_term?(term) when is_boolean(term), do: true |
| 39 | 0 | defp safe_term?(term) when is_list(term), do: Enum.all?(term, &safe_term?/1) |
| 40 | 0 | defp safe_term?(term) when is_tuple(term), do: Enum.all?(Tuple.to_list(term), &safe_term?/1) |
| 41 | 0 | defp safe_term?(_), do: false |
| 42 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Endpoint do | |
| 1 | use Phoenix.Endpoint, otp_app: :hexpm | |
| 2 | ||
| 3 | plug HexpmWeb.Plugs.Forwarded | |
| 4 | ||
| 5 | @session_options [ | |
| 6 | signing_salt: "NOcCmerj", | |
| 7 | store: HexpmWeb.Session, | |
| 8 | key: "_hexpm_key", | |
| 9 | max_age: 60 * 60 * 24 * 30 | |
| 10 | ] | |
| 11 | ||
| 12 | # Serve at "/" the static files from "priv/static" directory. | |
| 13 | # | |
| 14 | # You should set gzip to true if you are running phoenix.digest | |
| 15 | # when deploying your static files in production. | |
| 16 | plug Plug.Static, | |
| 17 | at: "/", | |
| 18 | from: :hexpm, | |
| 19 | gzip: true, | |
| 20 | only: ~w(css images js), | |
| 21 | only_matching: ~w(favicon robots) | |
| 22 | ||
| 23 | socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]) | |
| 24 | ||
| 25 | # Code reloading can be explicitly enabled under the | |
| 26 | # :code_reloader configuration of your endpoint. | |
| 27 | if code_reloading? do | |
| 28 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket | |
| 29 | plug Phoenix.LiveReloader | |
| 30 | plug Phoenix.CodeReloader | |
| 31 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :hexpm | |
| 32 | end | |
| 33 | ||
| 34 | plug HexpmWeb.Plugs.Status | |
| 35 | ||
| 36 | plug Phoenix.LiveDashboard.RequestLogger, | |
| 37 | param_key: "request_logger", | |
| 38 | cookie_key: "request_logger" | |
| 39 | ||
| 40 | plug Plug.RequestId | |
| 41 | plug Logster.Plugs.Logger, excludes: [:params] | |
| 42 | ||
| 43 | plug Plug.Parsers, | |
| 44 | parsers: [:urlencoded, :json, HexpmWeb.PlugParser], | |
| 45 | pass: ["*/*"], | |
| 46 | json_decoder: Jason | |
| 47 | ||
| 48 | plug Plug.MethodOverride | |
| 49 | plug Plug.Head | |
| 50 | plug HexpmWeb.Plugs.Vary, ["accept-encoding"] | |
| 51 | ||
| 52 | plug Plug.Session, @session_options | |
| 53 | ||
| 54 | if Mix.env() == :prod do | |
| 55 | plug Plug.SSL, rewrite_on: [:x_forwarded_proto] | |
| 56 | end | |
| 57 | ||
| 58 | plug HexpmWeb.Router | |
| 59 | ||
| 60 | def init(_key, config) do | |
| 61 | 1 | if config[:load_from_system_env] do |
| 62 | 0 | port = Application.get_env(:hexpm, :port) |
| 63 | ||
| 64 | 0 | case Integer.parse(port) do |
| 65 | {_int, ""} -> | |
| 66 | 0 | host = Application.get_env(:hexpm, :host) |
| 67 | 0 | secret_key_base = Application.get_env(:hexpm, :secret_key_base) |
| 68 | 0 | live_view_signing_salt = Application.get_env(:hexpm, :live_view_signing_salt) |
| 69 | ||
| 70 | 0 | config = put_in(config[:http][:port], port) |
| 71 | 0 | config = put_in(config[:url][:host], host) |
| 72 | 0 | config = put_in(config[:secret_key_base], secret_key_base) |
| 73 | 0 | config = put_in(config[:live_view][:signing_salt], live_view_signing_salt) |
| 74 | 0 | config = put_in(config[:check_origin], ["//#{host}"]) |
| 75 | ||
| 76 | {:ok, config} | |
| 77 | ||
| 78 | 0 | :error -> |
| 79 | {:ok, config} | |
| 80 | end | |
| 81 | else | |
| 82 | {:ok, config} | |
| 83 | end | |
| 84 | end | |
| 85 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.ErlangFormat do | |
| 1 | def encode_to_iodata!(term) do | |
| 2 | term | |
| 3 | |> Hexpm.Utils.binarify() | |
| 4 | 1 | |> :erlang.term_to_binary() |
| 5 | end | |
| 6 | ||
| 7 | @spec decode(binary) :: term | |
| 8 | 0 | def decode("") do |
| 9 | {:ok, nil} | |
| 10 | end | |
| 11 | ||
| 12 | 0 | def decode(<<131, 80, _rest::binary>>) do |
| 13 | {:error, "bad binary_to_term"} | |
| 14 | end | |
| 15 | ||
| 16 | 1 | def decode(binary) do |
| 17 | 1 | term = Plug.Crypto.non_executable_binary_to_term(binary, [:safe]) |
| 18 | {:ok, term} | |
| 19 | rescue | |
| 20 | 0 | ArgumentError -> |
| 21 | {:error, "bad binary_to_term"} | |
| 22 | end | |
| 23 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.MarkdownEngine do | |
| 1 | @behaviour Phoenix.Template.Engine | |
| 2 | ||
| 3 | def compile(path, _name) do | |
| 4 | 3 | html = |
| 5 | path | |
| 6 | |> File.read!() | |
| 7 | |> Earmark.as_html!(%Earmark.Options{gfm: true}) | |
| 8 | |> header_anchors("h3") | |
| 9 | |> header_anchors("h4") | |
| 10 | ||
| 11 | {:safe, html} | |
| 12 | end | |
| 13 | ||
| 14 | defp header_anchors(html, tag) do | |
| 15 | 6 | icon = |
| 16 | HexpmWeb.ViewIcons.icon(:glyphicon, :link, class: "icon-link") | |
| 17 | |> Phoenix.HTML.safe_to_string() | |
| 18 | ||
| 19 | 6 | Regex.replace(~r"<#{tag}>\n?(.*)<\/#{tag}>", html, fn _, header -> |
| 20 | 9 | anchor = |
| 21 | header | |
| 22 | |> String.downcase() | |
| 23 | |> dashify() | |
| 24 | |> only_alphanumeric() | |
| 25 | ||
| 26 | 9 | """ |
| 27 | 9 | <#{tag} id="#{anchor}" class="section-heading"> |
| 28 | 9 | <a href="##{anchor}" class="hover-link"> |
| 29 | 9 | #{icon} |
| 30 | </a> | |
| 31 | 9 | #{header} |
| 32 | 9 | </#{tag}> |
| 33 | """ | |
| 34 | end) | |
| 35 | end | |
| 36 | ||
| 37 | defp dashify(string) do | |
| 38 | 9 | String.replace(string, " ", "-") |
| 39 | end | |
| 40 | ||
| 41 | defp only_alphanumeric(string) do | |
| 42 | 9 | String.replace(string, ~r"([^a-zA-Z0-9\-])", "") |
| 43 | end | |
| 44 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.PlugParser do | |
| 1 | alias Plug.Conn | |
| 2 | ||
| 3 | @formats ~w(elixir erlang json) | |
| 4 | ||
| 5 | def parse(%Conn{} = conn, "application", "vnd.hex+" <> format, _headers, opts) | |
| 6 | when format in @formats do | |
| 7 | 1 | decoder = get_decoder(format, opts) |
| 8 | ||
| 9 | conn | |
| 10 | |> Conn.read_body(opts) | |
| 11 | 1 | |> decode(decoder) |
| 12 | end | |
| 13 | ||
| 14 | 58 | def parse(conn, _type, _subtype, _headers, _opts) do |
| 15 | {:next, conn} | |
| 16 | end | |
| 17 | ||
| 18 | defp decode({:more, _, conn}, _decoder) do | |
| 19 | 0 | {:error, :too_large, conn} |
| 20 | end | |
| 21 | ||
| 22 | defp decode({:error, :timeout}, _decoder) do | |
| 23 | 0 | raise Plug.TimeoutError |
| 24 | end | |
| 25 | ||
| 26 | defp decode({:error, _}, _decoder) do | |
| 27 | 0 | raise Plug.BadRequestError |
| 28 | end | |
| 29 | ||
| 30 | defp decode({:ok, "", conn}, _decoder) do | |
| 31 | 0 | {:ok, %{}, conn} |
| 32 | end | |
| 33 | ||
| 34 | defp decode({:ok, body, conn}, decoder) do | |
| 35 | 1 | case decoder.decode(body) do |
| 36 | {:ok, terms} when is_map(terms) -> | |
| 37 | 1 | {:ok, terms, conn} |
| 38 | ||
| 39 | {:ok, terms} -> | |
| 40 | 0 | {:ok, %{"_json" => terms}, conn} |
| 41 | ||
| 42 | {:error, reason} -> | |
| 43 | 0 | raise Plug.BadRequestError, message: reason |
| 44 | end | |
| 45 | end | |
| 46 | ||
| 47 | defp get_decoder(format, opts) do | |
| 48 | 1 | case format do |
| 49 | 0 | "elixir" -> HexpmWeb.ElixirFormat |
| 50 | 1 | "erlang" -> HexpmWeb.ErlangFormat |
| 51 | 0 | "json" -> Keyword.fetch!(opts, :json_decoder) |
| 52 | end | |
| 53 | end | |
| 54 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Plugs do | |
| 1 | import Plug.Conn, except: [read_body: 1] | |
| 2 | ||
| 3 | alias Hexpm.Accounts.Users | |
| 4 | alias HexpmWeb.ControllerHelpers | |
| 5 | ||
| 6 | # Max filesize: ~10mb | |
| 7 | # Min upload speed: ~10kb/s | |
| 8 | # Read 100kb every 10s | |
| 9 | @read_body_opts [ | |
| 10 | length: 10_000_000, | |
| 11 | read_length: 100_000, | |
| 12 | read_timeout: 10_000 | |
| 13 | ] | |
| 14 | ||
| 15 | def validate_url(conn, _opts) do | |
| 16 | 509 | if String.contains?(conn.request_path <> conn.query_string, "%00") do |
| 17 | conn | |
| 18 | |> ControllerHelpers.render_error(400) | |
| 19 | 0 | |> halt() |
| 20 | else | |
| 21 | 509 | conn |
| 22 | end | |
| 23 | end | |
| 24 | ||
| 25 | def fetch_body(conn, _opts) do | |
| 26 | 56 | {conn, body} = read_body(conn) |
| 27 | 56 | put_in(conn.params["body"], body) |
| 28 | end | |
| 29 | ||
| 30 | def read_body_finally(conn, _opts) do | |
| 31 | 58 | register_before_send(conn, fn conn -> |
| 32 | 58 | if conn.status in 200..399 do |
| 33 | 32 | conn |
| 34 | else | |
| 35 | # If we respond with an unsuccessful error code assume we did not read | |
| 36 | # body. Read the full body to avoid closing the connection too early, | |
| 37 | # works around getting H13/H18 errors on Heroku. | |
| 38 | 26 | case read_body(conn, @read_body_opts) do |
| 39 | 26 | {:ok, _body, conn} -> conn |
| 40 | 0 | _ -> conn |
| 41 | end | |
| 42 | end | |
| 43 | end) | |
| 44 | end | |
| 45 | ||
| 46 | defp read_body(conn) do | |
| 47 | 56 | case read_body(conn, @read_body_opts) do |
| 48 | 56 | {:ok, body, conn} -> |
| 49 | {conn, body} | |
| 50 | ||
| 51 | {:error, :timeout} -> | |
| 52 | 0 | raise Plug.TimeoutError |
| 53 | ||
| 54 | {:error, _} -> | |
| 55 | 0 | raise Plug.BadRequestError |
| 56 | ||
| 57 | {:more, _, _} -> | |
| 58 | 0 | raise Plug.Parsers.RequestTooLargeError |
| 59 | end | |
| 60 | end | |
| 61 | ||
| 62 | def user_agent(conn, opts) do | |
| 63 | 519 | case get_req_header(conn, "user-agent") do |
| 64 | [value | _] -> | |
| 65 | 0 | assign(conn, :user_agent, value) |
| 66 | ||
| 67 | [] -> | |
| 68 | 519 | if Keyword.get(opts, :required, true) && Application.get_env(:hexpm, :user_agent_req) do |
| 69 | 0 | ControllerHelpers.render_error(conn, 400, message: "User-Agent header is required") |
| 70 | else | |
| 71 | 519 | assign(conn, :user_agent, "missing") |
| 72 | end | |
| 73 | end | |
| 74 | end | |
| 75 | ||
| 76 | def default_repository(conn, _opts) do | |
| 77 | 508 | param_set? = Map.has_key?(conn.params, "repository") |
| 78 | ||
| 79 | 508 | case conn.path_info do |
| 80 | 8 | ["api", "packages"] -> conn |
| 81 | 24 | ["api", "publish"] when not param_set? -> put_in(conn.params["repository"], "hexpm") |
| 82 | 59 | ["api", "packages" | _] when not param_set? -> put_in(conn.params["repository"], "hexpm") |
| 83 | 22 | ["packages" | _] when not param_set? -> put_in(conn.params["repository"], "hexpm") |
| 84 | 395 | _ -> conn |
| 85 | end | |
| 86 | end | |
| 87 | ||
| 88 | def login(conn, _opts) do | |
| 89 | 199 | user_id = get_session(conn, "user_id") |
| 90 | 199 | user = user_id && Users.get_by_id(user_id, [:emails, organizations: :repository]) |
| 91 | 199 | conn = assign(conn, :current_organization, nil) |
| 92 | ||
| 93 | 199 | if user do |
| 94 | 130 | assign(conn, :current_user, user) |
| 95 | else | |
| 96 | 69 | assign(conn, :current_user, nil) |
| 97 | end | |
| 98 | end | |
| 99 | ||
| 100 | def disable_deactivated(conn, _opts) do | |
| 101 | 509 | if conn.assigns.current_user && conn.assigns.current_user.deactivated_at do |
| 102 | conn | |
| 103 | |> ControllerHelpers.render_error(400) | |
| 104 | 1 | |> halt() |
| 105 | else | |
| 106 | 508 | conn |
| 107 | end | |
| 108 | end | |
| 109 | ||
| 110 | def authenticate(conn, _opts) do | |
| 111 | 320 | case HexpmWeb.AuthHelpers.authenticate(conn) do |
| 112 | {:ok, %{key: key, user: user, organization: organization, email: email, source: source}} -> | |
| 113 | conn | |
| 114 | |> assign(:key, key) | |
| 115 | |> assign(:current_user, user) | |
| 116 | |> assign(:current_organization, organization) | |
| 117 | |> assign(:email, email) | |
| 118 | 237 | |> assign(:auth_source, source) |
| 119 | ||
| 120 | {:error, :missing} -> | |
| 121 | conn | |
| 122 | |> assign(:key, nil) | |
| 123 | |> assign(:current_user, nil) | |
| 124 | |> assign(:current_organization, nil) | |
| 125 | |> assign(:email, nil) | |
| 126 | 73 | |> assign(:auth_source, nil) |
| 127 | ||
| 128 | {:error, _} = error -> | |
| 129 | 10 | HexpmWeb.AuthHelpers.error(conn, error) |
| 130 | end | |
| 131 | end | |
| 132 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | # TODO: Don't rate limit conditional requests that return 304 Not Modified | |
| 1 | ||
| 2 | defmodule HexpmWeb.Plugs.Attack do | |
| 3 | use PlugAttack | |
| 4 | import HexpmWeb.ControllerHelpers | |
| 5 | import Plug.Conn | |
| 6 | alias Hexpm.BlockAddress | |
| 7 | alias HexpmWeb.RateLimitPubSub | |
| 8 | ||
| 9 | @storage {PlugAttack.Storage.Ets, HexpmWeb.Plugs.Attack.Storage} | |
| 10 | ||
| 11 | rule "allow local", conn do | |
| 12 | 1129 | allow(conn.remote_ip == {127, 0, 0, 1}) |
| 13 | end | |
| 14 | ||
| 15 | rule "allow addresses", conn do | |
| 16 | 619 | BlockAddress.try_reload() |
| 17 | 619 | allow(BlockAddress.allowed?(ip_string(conn.remote_ip))) |
| 18 | end | |
| 19 | ||
| 20 | rule "block addresses", conn do | |
| 21 | 618 | BlockAddress.try_reload() |
| 22 | 618 | block(BlockAddress.blocked?(ip_string(conn.remote_ip))) |
| 23 | end | |
| 24 | ||
| 25 | rule "user throttle", conn do | |
| 26 | 612 | user = conn.assigns[:current_user] |
| 27 | ||
| 28 | 612 | if api?(conn) && user do |
| 29 | 503 | user_throttle(user.id) |
| 30 | end | |
| 31 | end | |
| 32 | ||
| 33 | rule "organization throttle", conn do | |
| 34 | 109 | organization = conn.assigns[:current_organization] |
| 35 | ||
| 36 | 109 | if api?(conn) && organization do |
| 37 | 0 | organization_throttle(organization.id) |
| 38 | end | |
| 39 | end | |
| 40 | ||
| 41 | rule "ip throttle", conn do | |
| 42 | 109 | if api?(conn) do |
| 43 | 109 | ip_throttle(conn.remote_ip) |
| 44 | end | |
| 45 | end | |
| 46 | ||
| 47 | def allow_action(conn, {:throttle, data}, _opts) do | |
| 48 | 610 | add_throttling_headers(conn, data) |
| 49 | end | |
| 50 | ||
| 51 | def allow_action(conn, _data, _opts) do | |
| 52 | 511 | conn |
| 53 | end | |
| 54 | ||
| 55 | def block_action(conn, {:throttle, data}, _opts) do | |
| 56 | conn | |
| 57 | |> add_throttling_headers(data) | |
| 58 | 2 | |> render_error(429, message: "API rate limit exceeded for #{throttled_user(conn)}") |
| 59 | end | |
| 60 | ||
| 61 | def block_action(conn, _data, _opts) do | |
| 62 | 6 | render_error(conn, 403, message: "Blocked") |
| 63 | end | |
| 64 | ||
| 65 | defp add_throttling_headers(conn, data) do | |
| 66 | # The expires_at value is a unix time in milliseconds, we want to return one | |
| 67 | # in seconds | |
| 68 | 612 | reset = div(data[:expires_at], 1_000) |
| 69 | ||
| 70 | conn | |
| 71 | |> put_resp_header("x-ratelimit-limit", Integer.to_string(data[:limit])) | |
| 72 | |> put_resp_header("x-ratelimit-remaining", Integer.to_string(data[:remaining])) | |
| 73 | 612 | |> put_resp_header("x-ratelimit-reset", Integer.to_string(reset)) |
| 74 | end | |
| 75 | ||
| 76 | defp throttled_user(conn) do | |
| 77 | 2 | cond do |
| 78 | 2 | user = conn.assigns.current_user -> |
| 79 | 1 | "user #{user.id}" |
| 80 | ||
| 81 | 1 | organization = conn.assigns.current_organization -> |
| 82 | 0 | "organization #{organization.id}" |
| 83 | ||
| 84 | 1 | true -> |
| 85 | 1 | "IP #{ip_string(conn.remote_ip)}" |
| 86 | end | |
| 87 | end | |
| 88 | ||
| 89 | defp ip_string({a, b, c, d}) do | |
| 90 | 1238 | "#{a}.#{b}.#{c}.#{d}" |
| 91 | end | |
| 92 | ||
| 93 | def user_throttle(user_id, opts \\ []) do | |
| 94 | 505 | key = {:user, user_id} |
| 95 | 505 | time = opts[:time] || System.system_time(:millisecond) |
| 96 | 505 | unless opts[:time], do: RateLimitPubSub.broadcast(key, time) |
| 97 | ||
| 98 | 505 | timed_throttle( |
| 99 | key, | |
| 100 | time: time, | |
| 101 | storage: @storage, | |
| 102 | limit: 500, | |
| 103 | period: 60_000 | |
| 104 | ) | |
| 105 | end | |
| 106 | ||
| 107 | def organization_throttle(organization_id, opts \\ []) do | |
| 108 | 0 | key = {:organization, organization_id} |
| 109 | 0 | time = opts[:time] || System.system_time(:millisecond) |
| 110 | 0 | unless opts[:time], do: RateLimitPubSub.broadcast(key, time) |
| 111 | ||
| 112 | 0 | timed_throttle( |
| 113 | key, | |
| 114 | time: time, | |
| 115 | storage: @storage, | |
| 116 | limit: 500, | |
| 117 | period: 60_000 | |
| 118 | ) | |
| 119 | end | |
| 120 | ||
| 121 | def ip_throttle(ip, opts \\ []) do | |
| 122 | 111 | key = {:ip, ip} |
| 123 | 111 | time = opts[:time] || System.system_time(:millisecond) |
| 124 | 111 | unless opts[:time], do: RateLimitPubSub.broadcast(key, time) |
| 125 | ||
| 126 | 111 | timed_throttle( |
| 127 | key, | |
| 128 | time: time, | |
| 129 | storage: @storage, | |
| 130 | limit: 100, | |
| 131 | period: 60_000 | |
| 132 | ) | |
| 133 | end | |
| 134 | ||
| 135 | # From https://github.com/michalmuskala/plug_attack/blob/812ff857d0958f1a00a711273887d7187ae80a23/lib/rule.ex#L62 | |
| 136 | # Adding an option for `now` | |
| 137 | defp timed_throttle(key, opts) do | |
| 138 | 616 | if key do |
| 139 | 616 | do_throttle(key, opts) |
| 140 | else | |
| 141 | nil | |
| 142 | end | |
| 143 | end | |
| 144 | ||
| 145 | defp do_throttle(key, opts) do | |
| 146 | 616 | storage = Keyword.fetch!(opts, :storage) |
| 147 | 616 | limit = Keyword.fetch!(opts, :limit) |
| 148 | 616 | period = Keyword.fetch!(opts, :period) |
| 149 | 616 | now = Keyword.fetch!(opts, :time) |
| 150 | ||
| 151 | 616 | expires_at = expires_at(now, period) |
| 152 | 616 | count = do_throttle(storage, key, now, period, expires_at) |
| 153 | 616 | rem = limit - count |
| 154 | 616 | data = [period: period, expires_at: expires_at, limit: limit, remaining: max(rem, 0)] |
| 155 | 616 | {if(rem >= 0, do: :allow, else: :block), {:throttle, data}} |
| 156 | end | |
| 157 | ||
| 158 | 616 | defp expires_at(now, period), do: (div(now, period) + 1) * period |
| 159 | ||
| 160 | defp do_throttle({mod, opts}, key, now, period, expires_at) do | |
| 161 | 616 | full_key = {:throttle, key, div(now, period)} |
| 162 | 616 | mod.increment(opts, full_key, 1, expires_at) |
| 163 | end | |
| 164 | ||
| 165 | 830 | defp api?(%Plug.Conn{request_path: "/api/" <> _}), do: true |
| 166 | 0 | defp api?(%Plug.Conn{}), do: false |
| 167 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Plugs.DashboardAuth do | |
| 1 | @moduledoc """ | |
| 2 | Basic Auth for liveview dashboard | |
| 3 | """ | |
| 4 | ||
| 5 | import Plug.BasicAuth | |
| 6 | ||
| 7 | 0 | def init(_opts), do: :ok |
| 8 | ||
| 9 | def call(conn, _opts) do | |
| 10 | 0 | basic_auth(conn, |
| 11 | username: Application.get_env(:hexpm, :dashboard_user), | |
| 12 | password: Application.get_env(:hexpm, :dashboard_password) | |
| 13 | ) | |
| 14 | end | |
| 15 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Plugs.Forwarded do | |
| 1 | import Plug.Conn | |
| 2 | require Logger | |
| 3 | ||
| 4 | 0 | def init(opts), do: opts |
| 5 | ||
| 6 | def call(conn, _opts) do | |
| 7 | 533 | ip = get_req_header(conn, "x-forwarded-for") |
| 8 | 533 | %{conn | remote_ip: ip(ip) || conn.remote_ip} |
| 9 | end | |
| 10 | ||
| 11 | defp ip([ip | _]) do | |
| 12 | # According to https://cloud.google.com/load-balancing/docs/https/#components | |
| 13 | 0 | ip = String.split(ip, ",") |> Enum.at(-2) |
| 14 | ||
| 15 | 0 | if ip do |
| 16 | 0 | ip = String.trim(ip) |
| 17 | ||
| 18 | 0 | case :inet.parse_address(to_charlist(ip)) do |
| 19 | {:ok, parsed_ip} -> | |
| 20 | 0 | parsed_ip |
| 21 | ||
| 22 | {:error, _} -> | |
| 23 | 0 | Logger.warn("Invalid IP: #{inspect(ip)}") |
| 24 | nil | |
| 25 | end | |
| 26 | end | |
| 27 | end | |
| 28 | ||
| 29 | 533 | defp ip(_), do: nil |
| 30 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Plugs.Status do | |
| 1 | import Plug.Conn | |
| 2 | alias Plug.Conn | |
| 3 | ||
| 4 | 0 | def init(opts), do: opts |
| 5 | ||
| 6 | def call(%Conn{path_info: ["status"]} = conn, _opts) do | |
| 7 | conn | |
| 8 | |> send_resp(200, "") | |
| 9 | 0 | |> halt() |
| 10 | end | |
| 11 | ||
| 12 | def call(conn, _opts) do | |
| 13 | 533 | conn |
| 14 | end | |
| 15 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Plugs.Vary do | |
| 1 | import Plug.Conn | |
| 2 | ||
| 3 | 0 | def init(opts), do: opts |
| 4 | ||
| 5 | def call(conn, vary) do | |
| 6 | 533 | register_before_send(conn, fn conn -> |
| 7 | 533 | original_vary = get_resp_header(conn, "vary") |
| 8 | 533 | vary = Enum.join(original_vary ++ vary, ", ") |
| 9 | 533 | put_resp_header(conn, "vary", vary) |
| 10 | end) | |
| 11 | end | |
| 12 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.RateLimitPubSub do | |
| 1 | use GenServer | |
| 2 | alias HexpmWeb.Plugs.Attack | |
| 3 | ||
| 4 | def start_link(_) do | |
| 5 | 1 | GenServer.start_link(__MODULE__, [], name: __MODULE__) |
| 6 | end | |
| 7 | ||
| 8 | def broadcast(key, time) do | |
| 9 | 612 | server = GenServer.whereis(__MODULE__) |
| 10 | 612 | Phoenix.PubSub.broadcast_from!(Hexpm.PubSub, server, "ratelimit", {:throttle, key, time}) |
| 11 | end | |
| 12 | ||
| 13 | def init([]) do | |
| 14 | 1 | Phoenix.PubSub.subscribe(Hexpm.PubSub, "ratelimit") |
| 15 | {:ok, []} | |
| 16 | end | |
| 17 | ||
| 18 | def handle_info({:throttle, {:user, user_id}, time}, []) do | |
| 19 | 2 | Attack.user_throttle(user_id, time: time) |
| 20 | {:noreply, []} | |
| 21 | end | |
| 22 | ||
| 23 | def handle_info({:throttle, {:organization, organization_id}, time}, []) do | |
| 24 | 0 | Attack.organization_throttle(organization_id, time: time) |
| 25 | {:noreply, []} | |
| 26 | end | |
| 27 | ||
| 28 | def handle_info({:throttle, {:ip, ip}, time}, []) do | |
| 29 | 2 | Attack.ip_throttle(ip, time: time) |
| 30 | {:noreply, []} | |
| 31 | end | |
| 32 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Router do | |
| 1 | use HexpmWeb, :router | |
| 2 | use Plug.ErrorHandler | |
| 3 | import Phoenix.LiveDashboard.Router | |
| 4 | alias Hexpm.Accounts.{Organization, User} | |
| 5 | ||
| 6 | @accepted_formats ~w(json elixir erlang) | |
| 7 | ||
| 8 | 199 | pipeline :browser do |
| 9 | plug :accepts, ["html"] | |
| 10 | plug :fetch_session | |
| 11 | plug :fetch_flash | |
| 12 | plug :protect_from_forgery | |
| 13 | plug :put_secure_browser_headers | |
| 14 | plug :user_agent, required: false | |
| 15 | plug :validate_url | |
| 16 | plug HexpmWeb.Plugs.Attack | |
| 17 | plug :login | |
| 18 | plug :disable_deactivated | |
| 19 | plug :default_repository | |
| 20 | end | |
| 21 | ||
| 22 | 58 | pipeline :upload do |
| 23 | plug :read_body_finally | |
| 24 | plug :accepts, @accepted_formats | |
| 25 | plug :user_agent | |
| 26 | plug :authenticate | |
| 27 | plug :disable_deactivated | |
| 28 | plug :validate_url | |
| 29 | plug HexpmWeb.Plugs.Attack | |
| 30 | plug :fetch_body | |
| 31 | plug :default_repository | |
| 32 | end | |
| 33 | ||
| 34 | 262 | pipeline :api do |
| 35 | plug :accepts, @accepted_formats | |
| 36 | plug :user_agent | |
| 37 | plug :authenticate | |
| 38 | plug :disable_deactivated | |
| 39 | plug :validate_url | |
| 40 | plug HexpmWeb.Plugs.Attack | |
| 41 | plug Corsica, origins: "*", allow_methods: ["HEAD", "GET"] | |
| 42 | plug :default_repository | |
| 43 | end | |
| 44 | ||
| 45 | 0 | pipeline :admin do |
| 46 | plug HexpmWeb.Plugs.DashboardAuth | |
| 47 | end | |
| 48 | ||
| 49 | if Mix.env() == :dev do | |
| 50 | forward "/sent_emails", Bamboo.SentEmailViewerPlug | |
| 51 | end | |
| 52 | ||
| 53 | scope "/", HexpmWeb do | |
| 54 | pipe_through :browser | |
| 55 | ||
| 56 | 3 | get "/", PageController, :index |
| 57 | 1 | get "/about", PageController, :about |
| 58 | 1 | get "/pricing", PageController, :pricing |
| 59 | 1 | get "/sponsors", PageController, :sponsors |
| 60 | ||
| 61 | 1 | get "/login", LoginController, :show |
| 62 | 12 | post "/login", LoginController, :create |
| 63 | 1 | post "/logout", LoginController, :delete |
| 64 | ||
| 65 | 2 | get "/two_factor_auth", TFAAuthController, :show |
| 66 | 2 | post "/two_factor_auth", TFAAuthController, :create |
| 67 | ||
| 68 | 1 | get "/two_factor_auth/recovery", TFARecoveryController, :show |
| 69 | 2 | post "/two_factor_auth/recovery", TFARecoveryController, :create |
| 70 | ||
| 71 | 1 | get "/signup", SignupController, :show |
| 72 | 2 | post "/signup", SignupController, :create |
| 73 | ||
| 74 | 2 | get "/password/new", PasswordController, :show |
| 75 | 3 | post "/password/new", PasswordController, :update |
| 76 | ||
| 77 | 1 | get "/password/reset", PasswordResetController, :show |
| 78 | 2 | post "/password/reset", PasswordResetController, :create |
| 79 | ||
| 80 | 6 | get "/email/verify", EmailVerificationController, :verify |
| 81 | 1 | get "/email/verification", EmailVerificationController, :show |
| 82 | 3 | post "/email/verification", EmailVerificationController, :create |
| 83 | ||
| 84 | 2 | get "/dashboard", DashboardController, :index |
| 85 | ||
| 86 | 4 | get "/users/:username", UserController, :show |
| 87 | ||
| 88 | 0 | get "/orgs/:username", UserController, :show |
| 89 | ||
| 90 | 0 | get "/docs", DocsController, :index |
| 91 | 0 | get "/docs/usage", DocsController, :usage |
| 92 | 0 | get "/docs/publish", DocsController, :publish |
| 93 | 0 | get "/docs/tasks", DocsController, :tasks |
| 94 | 0 | get "/docs/rebar3_usage", DocsController, :rebar3_usage |
| 95 | 0 | get "/docs/rebar3_publish", DocsController, :rebar3_publish |
| 96 | 0 | get "/docs/rebar3_private", DocsController, :rebar3_private |
| 97 | 0 | get "/docs/rebar3_tasks", DocsController, :rebar3_tasks |
| 98 | 0 | get "/docs/private", DocsController, :private |
| 99 | 0 | get "/docs/faq", DocsController, :faq |
| 100 | 0 | get "/docs/mirrors", DocsController, :mirrors |
| 101 | 0 | get "/docs/public_keys", DocsController, :public_keys |
| 102 | 0 | get "/docs/self_hosting", DocsController, :self_hosting |
| 103 | ||
| 104 | 1 | get "/policies/codeofconduct", PolicyController, :coc |
| 105 | 1 | get "/policies/privacy", PolicyController, :privacy |
| 106 | 1 | get "/policies/termsofservice", PolicyController, :tos |
| 107 | 1 | get "/policies/copyright", PolicyController, :copyright |
| 108 | 0 | get "/policies/dispute", PolicyController, :dispute |
| 109 | ||
| 110 | 1 | get "/packages/:name/versions", VersionController, :index |
| 111 | 1 | get "/packages/:repository/:name/versions", VersionController, :index |
| 112 | ||
| 113 | 8 | get "/packages", PackageController, :index |
| 114 | 5 | get "/packages/:name", PackageController, :show |
| 115 | 2 | get "/packages/:name/audit_logs", PackageController, :audit_logs |
| 116 | 6 | get "/packages/:name/:version", PackageController, :show |
| 117 | 1 | get "/packages/:repository/:name/audit_logs", PackageController, :audit_logs |
| 118 | 4 | get "/packages/:repository/:name/:version", PackageController, :show |
| 119 | ||
| 120 | 0 | get "/blog", BlogController, :index |
| 121 | 0 | get "/blog/:slug", BlogController, :show |
| 122 | ||
| 123 | 2 | get "/l/:short_code", ShortURLController, :show |
| 124 | ||
| 125 | if Application.compile_env!(:hexpm, [:features, :package_reports]) do | |
| 126 | 1 | get "/reports", PackageReportController, :index |
| 127 | 0 | get "/reports/new", PackageReportController, :new |
| 128 | 0 | post "/reports/create", PackageReportController, :create |
| 129 | ||
| 130 | 21 | get "/reports/:id", PackageReportController, :show |
| 131 | 0 | post "/reports/:id/accept", PackageReportController, :accept_report |
| 132 | 0 | post "/reports/:id/reject", PackageReportController, :reject_report |
| 133 | 1 | post "/reports/:id/solve", PackageReportController, :solve_report |
| 134 | 0 | post "/reports/:id/unresolve", PackageReportController, :unresolve_report |
| 135 | 0 | post "/reports/:id/comment", PackageReportController, :new_comment |
| 136 | end | |
| 137 | end | |
| 138 | ||
| 139 | scope "/dashboard", HexpmWeb.Dashboard do | |
| 140 | pipe_through :browser | |
| 141 | ||
| 142 | 2 | get "/profile", ProfileController, :index |
| 143 | 5 | post "/profile", ProfileController, :update |
| 144 | ||
| 145 | 2 | get "/password", PasswordController, :index, as: :dashboard_password |
| 146 | 4 | post "/password", PasswordController, :update, as: :dashboard_password |
| 147 | ||
| 148 | 2 | get "/security", SecurityController, :index, as: :dashboard_security |
| 149 | 1 | post "/security/enable_tfa", SecurityController, :enable_tfa, as: :dashboard_security |
| 150 | 1 | post "/security/disable_tfa", SecurityController, :disable_tfa, as: :dashboard_security |
| 151 | ||
| 152 | 1 | post "/security/rotate_recovery_codes", SecurityController, :rotate_recovery_codes, |
| 153 | as: :dashboard_security | |
| 154 | ||
| 155 | 1 | post "/security/reset_auth_app", SecurityController, :reset_auth_app, as: :dashboard_security |
| 156 | ||
| 157 | 2 | get "/two_factor_auth/setup", TFAAuthSetupController, :index, as: :dashboard_tfa_setup |
| 158 | 2 | post "/two_factor_auth/setup", TFAAuthSetupController, :create, as: :dashboard_tfa_setup |
| 159 | ||
| 160 | 2 | get "/email", EmailController, :index |
| 161 | 3 | post "/email", EmailController, :create |
| 162 | 2 | delete "/email", EmailController, :delete |
| 163 | 3 | post "/email/primary", EmailController, :primary |
| 164 | 2 | post "/email/public", EmailController, :public |
| 165 | 1 | post "/email/resend", EmailController, :resend_verify |
| 166 | 3 | post "/email/gravatar", EmailController, :gravatar |
| 167 | ||
| 168 | 0 | get "/repos", OrganizationController, :redirect_repo |
| 169 | 0 | get "/repos/*glob", OrganizationController, :redirect_repo |
| 170 | 5 | get "/orgs/:dashboard_org", OrganizationController, :show |
| 171 | 4 | post "/orgs/:dashboard_org", OrganizationController, :update |
| 172 | 1 | post "/orgs/:dashboard_org/leave", OrganizationController, :leave |
| 173 | 2 | post "/orgs/:dashboard_org/billing-token", OrganizationController, :billing_token |
| 174 | 3 | post "/orgs/:dashboard_org/cancel-billing", OrganizationController, :cancel_billing |
| 175 | 2 | post "/orgs/:dashboard_org/update-billing", OrganizationController, :update_billing |
| 176 | 2 | post "/orgs/:dashboard_org/create-billing", OrganizationController, :create_billing |
| 177 | 3 | post "/orgs/:dashboard_org/add-seats", OrganizationController, :add_seats |
| 178 | 3 | post "/orgs/:dashboard_org/remove-seats", OrganizationController, :remove_seats |
| 179 | 2 | post "/orgs/:dashboard_org/change-plan", OrganizationController, :change_plan |
| 180 | 1 | post "/orgs/:dashboard_org/keys", OrganizationController, :create_key |
| 181 | 2 | delete "/orgs/:dashboard_org/keys", OrganizationController, :delete_key |
| 182 | 1 | get "/orgs/:dashboard_org/invoices/:id", OrganizationController, :show_invoice |
| 183 | 3 | post "/orgs/:dashboard_org/invoices/:id/pay", OrganizationController, :pay_invoice |
| 184 | 3 | post "/orgs/:dashboard_org/profile", OrganizationController, :update_profile |
| 185 | 1 | get "/orgs", OrganizationController, :new |
| 186 | 2 | post "/orgs", OrganizationController, :create |
| 187 | ||
| 188 | 2 | get "/keys", KeyController, :index |
| 189 | 2 | delete "/keys", KeyController, :delete |
| 190 | 1 | post "/keys", KeyController, :create |
| 191 | ||
| 192 | 4 | get "/audit_logs", AuditLogController, :index |
| 193 | end | |
| 194 | ||
| 195 | scope "/", HexpmWeb do | |
| 196 | 1 | get "/sitemap.xml", SitemapController, :main |
| 197 | 1 | get "/docs_sitemap.xml", SitemapController, :docs |
| 198 | 1 | get "/preview_sitemap.xml", SitemapController, :preview |
| 199 | 1 | get "/hexsearch.xml", OpenSearchController, :opensearch |
| 200 | 9 | get "/installs/hex.ez", InstallController, :archive |
| 201 | 1 | get "/feeds/blog.xml", FeedsController, :blog |
| 202 | end | |
| 203 | ||
| 204 | scope "/api", HexpmWeb.API, as: :api do | |
| 205 | pipe_through :upload | |
| 206 | ||
| 207 | for prefix <- ["/", "/repos/:repository"] do | |
| 208 | scope prefix do | |
| 209 | 26 | post "/publish", ReleaseController, :publish |
| 210 | 9 | post "/packages/:name/releases", ReleaseController, :create |
| 211 | 6 | post "/packages/:name/releases/:version/docs", DocsController, :create |
| 212 | end | |
| 213 | end | |
| 214 | end | |
| 215 | ||
| 216 | scope "/api", HexpmWeb.API, as: :api do | |
| 217 | pipe_through :api | |
| 218 | ||
| 219 | 1 | get "/", IndexController, :index |
| 220 | ||
| 221 | 4 | post "/users", UserController, :create |
| 222 | 3 | get "/users/me", UserController, :me |
| 223 | 3 | get "/users/me/audit_logs", UserController, :audit_logs |
| 224 | 5 | get "/users/:name", UserController, :show |
| 225 | # NOTE: Deprecated (2018-05-21) | |
| 226 | 2 | get "/users/:name/test", UserController, :test |
| 227 | 2 | post "/users/:name/reset", UserController, :reset |
| 228 | ||
| 229 | 2 | get "/orgs", OrganizationController, :index |
| 230 | 5 | get "/orgs/:organization", OrganizationController, :show |
| 231 | 4 | post "/orgs/:organization", OrganizationController, :update |
| 232 | 3 | get "/orgs/:organization/audit_logs", OrganizationController, :audit_logs |
| 233 | ||
| 234 | 3 | get "/orgs/:organization/members", OrganizationUserController, :index |
| 235 | 7 | post "/orgs/:organization/members", OrganizationUserController, :create |
| 236 | 3 | get "/orgs/:organization/members/:name", OrganizationUserController, :show |
| 237 | 5 | post "/orgs/:organization/members/:name", OrganizationUserController, :update |
| 238 | 5 | delete "/orgs/:organization/members/:name", OrganizationUserController, :delete |
| 239 | ||
| 240 | 2 | get "/repos", RepositoryController, :index |
| 241 | 4 | get "/repos/:repository", RepositoryController, :show |
| 242 | ||
| 243 | for prefix <- ["/", "/repos/:repository"] do | |
| 244 | scope prefix do | |
| 245 | 8 | get "/packages", PackageController, :index |
| 246 | 5 | get "/packages/:name", PackageController, :show |
| 247 | 1 | get "/packages/:name/audit_logs", PackageController, :audit_logs |
| 248 | ||
| 249 | 10 | get "/packages/:name/releases/:version", ReleaseController, :show |
| 250 | 5 | delete "/packages/:name/releases/:version", ReleaseController, :delete |
| 251 | ||
| 252 | 9 | post "/packages/:name/releases/:version/retire", RetirementController, :create |
| 253 | 9 | delete "/packages/:name/releases/:version/retire", RetirementController, :delete |
| 254 | ||
| 255 | 3 | get "/packages/:name/releases/:version/docs", DocsController, :show |
| 256 | 2 | delete "/packages/:name/releases/:version/docs", DocsController, :delete |
| 257 | ||
| 258 | 12 | get "/packages/:name/owners", OwnerController, :index |
| 259 | 5 | get "/packages/:name/owners/:username", OwnerController, :show |
| 260 | 13 | put "/packages/:name/owners/:username", OwnerController, :create |
| 261 | 9 | delete "/packages/:name/owners/:username", OwnerController, :delete |
| 262 | end | |
| 263 | end | |
| 264 | ||
| 265 | for prefix <- ["/", "/orgs/:organization"] do | |
| 266 | scope prefix do | |
| 267 | 7 | get "/keys", KeyController, :index |
| 268 | 2 | get "/keys/:name", KeyController, :show |
| 269 | 6 | post "/keys", KeyController, :create |
| 270 | 1 | delete "/keys", KeyController, :delete_all |
| 271 | 2 | delete "/keys/:name", KeyController, :delete |
| 272 | end | |
| 273 | end | |
| 274 | ||
| 275 | 2 | post "/short_url", ShortURLController, :create |
| 276 | 43 | get "/auth", AuthController, :show |
| 277 | end | |
| 278 | ||
| 279 | if Mix.env() in [:dev, :test, :hex] do | |
| 280 | scope "/repo", HexpmWeb do | |
| 281 | 0 | get "/names", TestController, :names |
| 282 | 0 | get "/versions", TestController, :versions |
| 283 | 0 | get "/installs/hex-1.x.csv", TestController, :installs_csv |
| 284 | ||
| 285 | for prefix <- ["/", "/repos/:repository"] do | |
| 286 | scope prefix do | |
| 287 | 0 | get "/packages/:package", TestController, :package |
| 288 | 0 | get "/tarballs/:ball", TestController, :tarball |
| 289 | end | |
| 290 | end | |
| 291 | end | |
| 292 | ||
| 293 | scope "/api", HexpmWeb do | |
| 294 | pipe_through :api | |
| 295 | ||
| 296 | 0 | post "/repo", TestController, :repo |
| 297 | end | |
| 298 | end | |
| 299 | ||
| 300 | scope "/" do | |
| 301 | pipe_through [:browser, :admin] | |
| 302 | 0 | live_dashboard("/db", metrics: HexpmWeb.Telemetry) |
| 303 | end | |
| 304 | ||
| 305 | def user_path(%User{organization: nil} = user) do | |
| 306 | 1 | Routes.user_path(Endpoint, :show, user) |
| 307 | end | |
| 308 | ||
| 309 | def user_path(%User{organization: %Organization{} = organization}) do | |
| 310 | # Work around for path helpers with duplicate routes | |
| 311 | 0 | "/orgs/#{organization.name}" |
| 312 | end | |
| 313 | ||
| 314 | defp handle_errors(conn, %{kind: kind, reason: reason, stack: stacktrace}) do | |
| 315 | 1 | if report?(kind, reason) do |
| 316 | 0 | conn = maybe_fetch_params(conn) |
| 317 | 0 | url = "#{conn.scheme}://#{conn.host}:#{conn.port}#{conn.request_path}" |
| 318 | 0 | user_ip = conn.remote_ip |> :inet.ntoa() |> List.to_string() |
| 319 | 0 | headers = conn.req_headers |> Map.new() |> filter_headers() |
| 320 | 0 | params = filter_params(conn.params) |
| 321 | 0 | endpoint_url = HexpmWeb.Endpoint.config(:url) |
| 322 | ||
| 323 | 0 | conn_data = %{ |
| 324 | "request" => %{ | |
| 325 | "url" => url, | |
| 326 | "user_ip" => user_ip, | |
| 327 | "headers" => headers, | |
| 328 | "params" => params, | |
| 329 | 0 | "method" => conn.method |
| 330 | }, | |
| 331 | "server" => %{ | |
| 332 | "host" => endpoint_url[:host], | |
| 333 | "root" => endpoint_url[:path] | |
| 334 | } | |
| 335 | } | |
| 336 | ||
| 337 | 0 | Rollbax.report(kind, reason, stacktrace, %{}, conn_data) |
| 338 | end | |
| 339 | end | |
| 340 | ||
| 341 | 1 | defp report?(:error, %Hexpm.WriteInReadOnlyMode{}), do: false |
| 342 | 0 | defp report?(:error, exception), do: Plug.Exception.status(exception) == 500 |
| 343 | 0 | defp report?(_kind, _reason), do: true |
| 344 | ||
| 345 | defp maybe_fetch_params(conn) do | |
| 346 | 0 | try do |
| 347 | 0 | Plug.Conn.fetch_query_params(conn) |
| 348 | rescue | |
| 349 | _ -> | |
| 350 | 0 | %{conn | params: "[UNFETCHED]"} |
| 351 | end | |
| 352 | end | |
| 353 | ||
| 354 | @filter_headers ~w(authorization) | |
| 355 | ||
| 356 | defp filter_headers(headers) do | |
| 357 | 0 | Map.drop(headers, @filter_headers) |
| 358 | end | |
| 359 | ||
| 360 | @filter_params ~w(body password password_confirmation) | |
| 361 | ||
| 362 | defp filter_params(params) when is_map(params) do | |
| 363 | 0 | Map.new(params, fn {key, value} -> |
| 364 | 0 | if key in @filter_params do |
| 365 | {key, "[FILTERED]"} | |
| 366 | else | |
| 367 | {key, filter_params(value)} | |
| 368 | end | |
| 369 | end) | |
| 370 | end | |
| 371 | ||
| 372 | defp filter_params(params) when is_list(params) do | |
| 373 | 0 | Enum.map(params, &filter_params/1) |
| 374 | end | |
| 375 | ||
| 376 | defp filter_params(other) do | |
| 377 | 0 | other |
| 378 | end | |
| 379 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Session do | |
| 1 | alias Hexpm.Accounts.Session | |
| 2 | alias Hexpm.Repo | |
| 3 | ||
| 4 | @behaviour Plug.Session.Store | |
| 5 | ||
| 6 | 0 | def init(_opts) do |
| 7 | :ok | |
| 8 | end | |
| 9 | ||
| 10 | def get(_conn, cookie, _opts) do | |
| 11 | 4 | with {id, "++" <> token} <- Integer.parse(cookie), |
| 12 | 4 | {:ok, token} <- Base.url_decode64(token), |
| 13 | 4 | session = Repo.get(Session, id), |
| 14 | 4 | true <- session && Plug.Crypto.secure_compare(token, session.token) do |
| 15 | 4 | {{id, token}, session.data} |
| 16 | else | |
| 17 | _ -> | |
| 18 | {nil, %{}} | |
| 19 | end | |
| 20 | end | |
| 21 | ||
| 22 | def put(_conn, nil, data, _opts) do | |
| 23 | 187 | session = Session.build(data) |
| 24 | ||
| 25 | 187 | session = |
| 26 | if Repo.write_mode?() do | |
| 27 | 187 | Repo.insert!(session) |
| 28 | else | |
| 29 | 0 | Ecto.Changeset.apply_changes(session) |
| 30 | end | |
| 31 | ||
| 32 | 187 | build_cookie(session) |
| 33 | end | |
| 34 | ||
| 35 | def put(_conn, {id, token}, data, _opts) do | |
| 36 | 3 | if Repo.write_mode?() do |
| 37 | 3 | Repo.update_all( |
| 38 | Session.by_id(id), | |
| 39 | set: [ | |
| 40 | data: data, | |
| 41 | updated_at: DateTime.utc_now() | |
| 42 | ] | |
| 43 | ) | |
| 44 | end | |
| 45 | ||
| 46 | 3 | build_cookie(id, token) |
| 47 | end | |
| 48 | ||
| 49 | def delete(_conn, {id, _token}, _opts) do | |
| 50 | 1 | if Repo.write_mode?() do |
| 51 | 1 | Repo.delete_all(Session.by_id(id)) |
| 52 | end | |
| 53 | ||
| 54 | :ok | |
| 55 | end | |
| 56 | ||
| 57 | defp build_cookie(session) do | |
| 58 | 187 | build_cookie(session.id, session.token) |
| 59 | end | |
| 60 | ||
| 61 | defp build_cookie(id, token) do | |
| 62 | 190 | "#{id}++#{Base.url_encode64(token)}" |
| 63 | end | |
| 64 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defprotocol HexpmWeb.Stale do | |
| 1 | 95 | def etag(schema) |
| 2 | 47 | def last_modified(schema) |
| 3 | end | |
| 4 | ||
| 5 | defimpl HexpmWeb.Stale, for: Atom do | |
| 6 | 0 | def etag(nil), do: nil |
| 7 | ||
| 8 | # This is not a good solution because we don't know when a missing | |
| 9 | # association was modified but this is the best we have for now | |
| 10 | 0 | def last_modified(nil), do: ~N[0000-01-01 00:00:00] |
| 11 | end | |
| 12 | ||
| 13 | defimpl HexpmWeb.Stale, for: Any do | |
| 14 | defmacro __deriving__(module, _struct, opts) do | |
| 15 | 0 | etag_keys = Keyword.get(opts, :etag, [:__struct__, :id, :updated_at]) |
| 16 | 0 | last_modified_key = Keyword.get(opts, :last_modified, :updated_at) |
| 17 | 0 | assocs = Keyword.get(opts, :assocs, []) |
| 18 | ||
| 19 | quote do | |
| 20 | defimpl HexpmWeb.Stale, for: unquote(module) do | |
| 21 | alias HexpmWeb.Stale | |
| 22 | alias HexpmWeb.Stale.Any | |
| 23 | ||
| 24 | def etag(schema) do | |
| 25 | assocs = unquote(assocs) | |
| 26 | etag_keys = unquote(etag_keys) | |
| 27 | [Map.take(schema, etag_keys), Any.recurse_fields(schema, assocs, &Stale.etag/1)] | |
| 28 | end | |
| 29 | ||
| 30 | def last_modified(schema) do | |
| 31 | assocs = unquote(assocs) | |
| 32 | last_modified_key = unquote(last_modified_key) | |
| 33 | last_modified = fetch_last_modified(schema, last_modified_key) | |
| 34 | [last_modified, Any.recurse_fields(schema, assocs, &Stale.last_modified/1)] | |
| 35 | end | |
| 36 | ||
| 37 | defp fetch_last_modified(_schema, nil), do: ~N[0000-01-01 00:00:00] | |
| 38 | defp fetch_last_modified(schema, key), do: Map.fetch!(schema, key) | |
| 39 | end | |
| 40 | end | |
| 41 | end | |
| 42 | ||
| 43 | 0 | def etag(_), do: raise("not implemented") |
| 44 | 0 | def last_modified(_), do: raise("not implemented") |
| 45 | ||
| 46 | def recurse_fields(schema, keys, fun) do | |
| 47 | 142 | Enum.map(keys, fn key -> |
| 48 | Map.fetch!(schema, key) | |
| 49 | 250 | |> recurse_field(fun) |
| 50 | end) | |
| 51 | end | |
| 52 | ||
| 53 | 152 | defp recurse_field(%Ecto.Association.NotLoaded{}, _fun), do: [] |
| 54 | 98 | defp recurse_field(schemas, fun) when is_list(schemas), do: Enum.map(schemas, fun) |
| 55 | 0 | defp recurse_field(schema, fun), do: fun.(schema) |
| 56 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Telemetry do | |
| 1 | use Supervisor | |
| 2 | import Telemetry.Metrics | |
| 3 | ||
| 4 | def start_link(arg) do | |
| 5 | 1 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) |
| 6 | end | |
| 7 | ||
| 8 | @impl true | |
| 9 | def init(_arg) do | |
| 10 | 1 | children = [ |
| 11 | # Telemetry poller will execute the given period measurements | |
| 12 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics | |
| 13 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} | |
| 14 | # Add reporters as children of your supervision tree. | |
| 15 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} | |
| 16 | ] | |
| 17 | ||
| 18 | 1 | Supervisor.init(children, strategy: :one_for_one) |
| 19 | end | |
| 20 | ||
| 21 | 0 | def metrics do |
| 22 | [ | |
| 23 | # Phoenix Metrics | |
| 24 | summary("phoenix.endpoint.stop.duration", | |
| 25 | unit: {:native, :millisecond} | |
| 26 | ), | |
| 27 | summary("phoenix.router_dispatch.stop.duration", | |
| 28 | tags: [:route], | |
| 29 | unit: {:native, :millisecond} | |
| 30 | ), | |
| 31 | ||
| 32 | # Database Time Metrics | |
| 33 | summary("hexpm.repo_base.query.total_time", unit: {:native, :millisecond}), | |
| 34 | summary("hexpm.repo_base.query.decode_time", unit: {:native, :millisecond}), | |
| 35 | summary("hexpm.repo_base.query.query_time", unit: {:native, :millisecond}), | |
| 36 | summary("hexpm.repo_base.query.queue_time", unit: {:native, :millisecond}), | |
| 37 | summary("hexpm.repo_base.query.idle_time", unit: {:native, :millisecond}), | |
| 38 | ||
| 39 | # VM Metrics | |
| 40 | summary("vm.memory.total", unit: {:byte, :kilobyte}), | |
| 41 | summary("vm.total_run_queue_lengths.total"), | |
| 42 | summary("vm.total_run_queue_lengths.cpu"), | |
| 43 | summary("vm.total_run_queue_lengths.io") | |
| 44 | ] | |
| 45 | end | |
| 46 | ||
| 47 | 1 | defp periodic_measurements do |
| 48 | [] | |
| 49 | end | |
| 50 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.AuditLogView do | |
| 1 | use HexpmWeb, :view | |
| 2 | ||
| 3 | def render("show", %{audit_log: audit_log}) do | |
| 4 | 6 | Map.take(audit_log, [:action, :user_agent, :params]) |
| 5 | end | |
| 6 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.DownloadView do | |
| 1 | use HexpmWeb, :view | |
| 2 | ||
| 3 | def render("show." <> _, %{download: download}) do | |
| 4 | 0 | render_one(download, __MODULE__, "show") |
| 5 | end | |
| 6 | ||
| 7 | def render("show", %{download: download}) do | |
| 8 | 0 | %{download.view => download.downloads} |
| 9 | end | |
| 10 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.IndexView do | |
| 1 | use HexpmWeb, :view | |
| 2 | ||
| 3 | def render("index." <> _format, _assigns) do | |
| 4 | 1 | %{ |
| 5 | packages_url: Routes.api_package_url(Endpoint, :index), | |
| 6 | package_url: Routes.api_package_url(Endpoint, :show, "{name}") |> fix_placeholder(), | |
| 7 | package_release_url: | |
| 8 | Routes.api_release_url(Endpoint, :show, "{name}", "{version}") |> fix_placeholder(), | |
| 9 | package_owners_url: Routes.api_owner_url(Endpoint, :index, "{name}") |> fix_placeholder(), | |
| 10 | keys_url: Routes.api_key_url(Endpoint, :index), | |
| 11 | key_url: Routes.api_key_url(Endpoint, :show, "{name}") |> fix_placeholder(), | |
| 12 | documentation_url: "http://docs.hexpm.apiary.io" | |
| 13 | } | |
| 14 | end | |
| 15 | ||
| 16 | defp fix_placeholder(url) do | |
| 17 | url | |
| 18 | |> String.replace("%7B", "{") | |
| 19 | 4 | |> String.replace("%7D", "}") |
| 20 | end | |
| 21 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.KeyPermissionView do | |
| 1 | use HexpmWeb, :view | |
| 2 | ||
| 3 | def render("show." <> _, %{key_permission: key_permission}) do | |
| 4 | 15 | render_one(key_permission, __MODULE__, "show") |
| 5 | end | |
| 6 | ||
| 7 | def render("show", %{key_permission: key_permission}) do | |
| 8 | 15 | %{ |
| 9 | 15 | domain: key_permission.domain, |
| 10 | 15 | resource: key_permission.resource |
| 11 | } | |
| 12 | end | |
| 13 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.KeyView do | |
| 1 | use HexpmWeb, :view | |
| 2 | alias HexpmWeb.API.KeyPermissionView | |
| 3 | ||
| 4 | def render("index." <> _, %{keys: keys, authing_key: authing_key}) do | |
| 5 | 4 | render_many(keys, __MODULE__, "show", authing_key: authing_key) |
| 6 | end | |
| 7 | ||
| 8 | def render("show." <> _, %{key: key, authing_key: authing_key}) do | |
| 9 | 5 | render_one(key, __MODULE__, "show", authing_key: authing_key) |
| 10 | end | |
| 11 | ||
| 12 | def render("delete." <> _, %{key: key, authing_key: authing_key}) do | |
| 13 | 3 | render_one(key, __MODULE__, "show", authing_key: authing_key) |
| 14 | end | |
| 15 | ||
| 16 | def render("show", %{key: key, authing_key: authing_key}) do | |
| 17 | %{ | |
| 18 | 15 | name: key.name, |
| 19 | 15 | authing_key: !!(authing_key && key.id == authing_key.id), |
| 20 | 15 | secret: key.user_secret, |
| 21 | 15 | permissions: render_many(key.permissions, KeyPermissionView, "show.json"), |
| 22 | 15 | revoked_at: key.revoked_at, |
| 23 | 15 | inserted_at: key.inserted_at, |
| 24 | 15 | updated_at: key.updated_at, |
| 25 | url: Routes.api_key_url(Endpoint, :show, key) | |
| 26 | } | |
| 27 | 15 | |> ViewHelpers.include_if_loaded(:last_use, key.last_use, &render_use/1) |
| 28 | end | |
| 29 | ||
| 30 | defp render_use(use) do | |
| 31 | 6 | %{ |
| 32 | 6 | used_at: use.used_at, |
| 33 | 6 | ip: use.ip, |
| 34 | 6 | user_agent: use.user_agent |
| 35 | } | |
| 36 | end | |
| 37 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.OrganizationUserView do | |
| 1 | use HexpmWeb, :view | |
| 2 | alias HexpmWeb.API.UserView | |
| 3 | ||
| 4 | def render("index." <> _, %{organization_users: organization_users}) do | |
| 5 | 1 | render_many(organization_users, __MODULE__, "show") |
| 6 | end | |
| 7 | ||
| 8 | def render("show." <> _, %{user: user, role: role}) do | |
| 9 | render_one(user, UserView, "show") | |
| 10 | 3 | |> Map.merge(%{role: role}) |
| 11 | end | |
| 12 | ||
| 13 | def render("show", %{organization_user: organization_user}) do | |
| 14 | 1 | render("show", %{user: organization_user.user, role: organization_user.role}) |
| 15 | end | |
| 16 | ||
| 17 | def render("show", %{user: user, role: role}) do | |
| 18 | render_one(user, UserView, "minimal") | |
| 19 | 1 | |> Map.merge(%{role: role}) |
| 20 | end | |
| 21 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.OrganizationView do | |
| 1 | use HexpmWeb, :view | |
| 2 | alias HexpmWeb.API.OrganizationUserView | |
| 3 | ||
| 4 | def render("index." <> _, %{organizations: organizations}) do | |
| 5 | 2 | Enum.map(organizations, fn organization -> |
| 6 | 1 | %{ |
| 7 | 1 | name: organization.name, |
| 8 | 1 | billing_active: organization.billing_active, |
| 9 | 1 | inserted_at: organization.inserted_at, |
| 10 | 1 | updated_at: organization.updated_at |
| 11 | } | |
| 12 | end) | |
| 13 | end | |
| 14 | ||
| 15 | def render("show." <> _, %{organization: organization, customer: customer}) do | |
| 16 | %{ | |
| 17 | 2 | name: organization.name, |
| 18 | 2 | billing_active: organization.billing_active, |
| 19 | 2 | inserted_at: organization.inserted_at, |
| 20 | 2 | updated_at: organization.updated_at, |
| 21 | seats: customer["quantity"] | |
| 22 | } | |
| 23 | 2 | |> ViewHelpers.include_if_loaded( |
| 24 | :users, | |
| 25 | 2 | organization.organization_users, |
| 26 | OrganizationUserView, | |
| 27 | "show" | |
| 28 | ) | |
| 29 | end | |
| 30 | ||
| 31 | def render("audit_logs." <> _, %{audit_logs: audit_logs}) do | |
| 32 | 1 | render_many(audit_logs, HexpmWeb.API.AuditLogView, "show") |
| 33 | end | |
| 34 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.OwnerView do | |
| 1 | use HexpmWeb, :view | |
| 2 | alias HexpmWeb.API.{OwnerView, UserView} | |
| 3 | ||
| 4 | def render("index." <> _format, %{owners: owners}) do | |
| 5 | 4 | render_many(owners, OwnerView, "show") |
| 6 | end | |
| 7 | ||
| 8 | def render("show." <> _format, %{owner: owner}) do | |
| 9 | 2 | render_one(owner, OwnerView, "show") |
| 10 | end | |
| 11 | ||
| 12 | def render("show", %{owner: owner}) do | |
| 13 | 7 | render(UserView, "show", user: owner.user) |
| 14 | 7 | |> Map.put(:level, owner.level) |
| 15 | end | |
| 16 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.PackageView do | |
| 1 | use HexpmWeb, :view | |
| 2 | alias HexpmWeb.API.{DownloadView, ReleaseView, RetirementView, UserView} | |
| 3 | alias HexpmWeb.PackageView | |
| 4 | ||
| 5 | def render("index." <> _, %{packages: packages}) do | |
| 6 | 10 | render_many(packages, __MODULE__, "show") |
| 7 | end | |
| 8 | ||
| 9 | def render("show." <> _, %{package: package}) do | |
| 10 | 3 | render_one(package, __MODULE__, "show") |
| 11 | end | |
| 12 | ||
| 13 | def render("audit_logs." <> _, %{audit_logs: audit_logs}) do | |
| 14 | 1 | render_many(audit_logs, HexpmWeb.API.AuditLogView, "show") |
| 15 | end | |
| 16 | ||
| 17 | def render("show", %{package: package}) do | |
| 18 | 23 | latest_release = Release.latest_version(package.releases, only_stable: false) |
| 19 | 23 | latest_stable_release = Release.latest_version(package.releases, only_stable: true) |
| 20 | 23 | release = latest_stable_release || latest_release |
| 21 | ||
| 22 | %{ | |
| 23 | 23 | repository: package.repository.name, |
| 24 | 23 | name: package.name, |
| 25 | 23 | inserted_at: package.inserted_at, |
| 26 | 23 | updated_at: package.updated_at, |
| 27 | url: ViewHelpers.url_for_package(package), | |
| 28 | html_url: ViewHelpers.html_url_for_package(package), | |
| 29 | docs_html_url: ViewHelpers.docs_html_url_for_package(package), | |
| 30 | 23 | latest_version: latest_release.version, |
| 31 | 23 | latest_stable_version: latest_stable_release && latest_stable_release.version, |
| 32 | configs: %{ | |
| 33 | "mix.exs": PackageView.dep_snippet(:mix, package, release), | |
| 34 | "rebar.config": PackageView.dep_snippet(:rebar, package, release), | |
| 35 | "erlang.mk": PackageView.dep_snippet(:erlang_mk, package, release) | |
| 36 | }, | |
| 37 | meta: %{ | |
| 38 | 23 | description: package.meta.description, |
| 39 | 23 | licenses: package.meta.licenses || [], |
| 40 | 23 | links: package.meta.links || %{}, |
| 41 | 23 | maintainers: package.meta.maintainers || [] |
| 42 | } | |
| 43 | } | |
| 44 | |> ViewHelpers.include_if_loaded( | |
| 45 | :releases, | |
| 46 | 23 | package.releases, |
| 47 | ReleaseView, | |
| 48 | "minimal.json", | |
| 49 | package: package | |
| 50 | ) | |
| 51 | |> ViewHelpers.include_if_loaded( | |
| 52 | :retirements, | |
| 53 | 23 | package.releases, |
| 54 | RetirementView, | |
| 55 | "package.json" | |
| 56 | ) | |
| 57 | 23 | |> ViewHelpers.include_if_loaded(:downloads, package.downloads, DownloadView, "show.json") |
| 58 | 23 | |> ViewHelpers.include_if_loaded(:owners, package.owners, UserView, "minimal.json") |
| 59 | |> group_downloads() | |
| 60 | 23 | |> group_retirements() |
| 61 | end | |
| 62 | ||
| 63 | defp group_downloads(%{downloads: downloads} = package) do | |
| 64 | 23 | Map.put(package, :downloads, Enum.reduce(downloads, %{}, &Map.merge(&1, &2))) |
| 65 | end | |
| 66 | ||
| 67 | 0 | defp group_downloads(package), do: package |
| 68 | ||
| 69 | defp group_retirements(%{retirements: retirements} = package) do | |
| 70 | 23 | Map.put(package, :retirements, Enum.reduce(retirements, %{}, &Map.merge(&1, &2))) |
| 71 | end | |
| 72 | ||
| 73 | 0 | defp group_retirements(package), do: package |
| 74 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.ReleaseView do | |
| 1 | use HexpmWeb, :view | |
| 2 | alias HexpmWeb.API.{RetirementView, UserView} | |
| 3 | alias HexpmWeb.PackageView | |
| 4 | ||
| 5 | def render("show." <> _, %{release: release}) do | |
| 6 | 33 | render_one(release, __MODULE__, "show") |
| 7 | end | |
| 8 | ||
| 9 | def render("minimal." <> _, %{release: release, package: package}) do | |
| 10 | 29 | render_one(release, __MODULE__, "minimal", %{package: package}) |
| 11 | end | |
| 12 | ||
| 13 | def render("show", %{release: release}) do | |
| 14 | 33 | %{ |
| 15 | 33 | version: release.version, |
| 16 | 33 | checksum: Base.encode16(release.outer_checksum, case: :lower), |
| 17 | 33 | has_docs: release.has_docs, |
| 18 | 33 | inserted_at: release.inserted_at, |
| 19 | 33 | updated_at: release.updated_at, |
| 20 | 33 | retirement: render_one(release.retirement, RetirementView, "show.json"), |
| 21 | 33 | package_url: ViewHelpers.url_for_package(release.package), |
| 22 | 33 | url: ViewHelpers.url_for_release(release.package, release), |
| 23 | 33 | html_url: ViewHelpers.html_url_for_release(release.package, release), |
| 24 | 33 | docs_html_url: ViewHelpers.docs_html_url_for_release(release.package, release), |
| 25 | 33 | requirements: requirements(release.requirements), |
| 26 | configs: %{ | |
| 27 | 33 | "mix.exs": PackageView.dep_snippet(:mix, release.package, release), |
| 28 | 33 | "rebar.config": PackageView.dep_snippet(:rebar, release.package, release), |
| 29 | 33 | "erlang.mk": PackageView.dep_snippet(:erlang_mk, release.package, release) |
| 30 | }, | |
| 31 | meta: %{ | |
| 32 | 33 | app: release.meta.app, |
| 33 | 33 | build_tools: Enum.uniq(release.meta.build_tools), |
| 34 | 33 | elixir: release.meta.elixir |
| 35 | }, | |
| 36 | 33 | downloads: downloads(release.downloads), |
| 37 | 33 | publisher: render_one(release.publisher, UserView, "minimal.json") |
| 38 | } | |
| 39 | end | |
| 40 | ||
| 41 | def render("minimal", %{release: release, package: package}) do | |
| 42 | 29 | %{ |
| 43 | 29 | version: release.version, |
| 44 | url: ViewHelpers.url_for_release(package, release), | |
| 45 | 29 | has_docs: release.has_docs, |
| 46 | 29 | inserted_at: release.inserted_at |
| 47 | } | |
| 48 | end | |
| 49 | ||
| 50 | defp requirements(requirements) do | |
| 51 | 33 | Enum.into(requirements, %{}, fn req -> |
| 52 | 4 | {req.name, Map.take(req, ~w(app requirement optional)a)} |
| 53 | end) | |
| 54 | end | |
| 55 | ||
| 56 | 25 | defp downloads(%Ecto.Association.NotLoaded{}), do: nil |
| 57 | ||
| 58 | defp downloads([%Download{day: nil, downloads: downloads}]) do | |
| 59 | 6 | downloads |
| 60 | end | |
| 61 | ||
| 62 | defp downloads(downloads) when is_list(downloads) do | |
| 63 | 2 | Enum.map(downloads, fn download -> |
| 64 | 6 | [download.day, download.downloads] |
| 65 | end) | |
| 66 | end | |
| 67 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.RepositoryView do | |
| 1 | use HexpmWeb, :view | |
| 2 | ||
| 3 | def render("index." <> _, %{repositories: repositories}), | |
| 4 | 2 | do: render_many(repositories, __MODULE__, "show") |
| 5 | ||
| 6 | def render("show." <> _, %{repository: repository}), | |
| 7 | 2 | do: render_one(repository, __MODULE__, "show") |
| 8 | ||
| 9 | def render("show", %{repository: repository}) do | |
| 10 | # TODO: Add url | |
| 11 | # TODO: Add packages | |
| 12 | ||
| 13 | 5 | %{ |
| 14 | 5 | name: repository.name, |
| 15 | 5 | inserted_at: repository.inserted_at, |
| 16 | 5 | updated_at: repository.updated_at |
| 17 | } | |
| 18 | end | |
| 19 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.RetirementView do | |
| 1 | use HexpmWeb, :view | |
| 2 | ||
| 3 | def render("show." <> _, %{retirement: retirement}) do | |
| 4 | 0 | render_one(retirement, __MODULE__, "show") |
| 5 | end | |
| 6 | ||
| 7 | def render("package." <> _, %{retirement: retirement}) do | |
| 8 | 29 | render_one(retirement, __MODULE__, "package") |
| 9 | end | |
| 10 | ||
| 11 | def render("show", %{retirement: retirement}) do | |
| 12 | 0 | %{ |
| 13 | 0 | message: retirement.message, |
| 14 | 0 | reason: retirement.reason |
| 15 | } | |
| 16 | end | |
| 17 | ||
| 18 | 28 | def render("package", %{retirement: %{retirement: nil}}), do: %{} |
| 19 | ||
| 20 | def render("package", %{retirement: %{retirement: retirement, version: version}}) do | |
| 21 | 1 | %{ |
| 22 | 1 | version => %{reason: retirement.reason, message: retirement.message} |
| 23 | } | |
| 24 | end | |
| 25 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.ShortURLView do | |
| 1 | use HexpmWeb, :view | |
| 2 | ||
| 3 | def render("show." <> _, %{url: url}) do | |
| 4 | 1 | render(__MODULE__, "show", url: url) |
| 5 | end | |
| 6 | ||
| 7 | def render("show", %{url: url}) do | |
| 8 | 1 | %{url: url} |
| 9 | end | |
| 10 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.API.UserView do | |
| 1 | use HexpmWeb, :view | |
| 2 | ||
| 3 | def render("index." <> _, %{users: users}) do | |
| 4 | 0 | render_many(users, __MODULE__, "show") |
| 5 | end | |
| 6 | ||
| 7 | def render("show." <> _, %{user: user}) do | |
| 8 | 8 | render_one(user, __MODULE__, "show") |
| 9 | end | |
| 10 | ||
| 11 | def render("me." <> _, %{user: user}) do | |
| 12 | 1 | render_one(user, __MODULE__, "me") |
| 13 | end | |
| 14 | ||
| 15 | def render("minimal." <> _, %{user: user}) do | |
| 16 | 22 | render_one(user, __MODULE__, "minimal") |
| 17 | end | |
| 18 | ||
| 19 | def render("audit_logs." <> _, %{audit_logs: audit_logs}) do | |
| 20 | 1 | render_many(audit_logs, HexpmWeb.API.AuditLogView, "show") |
| 21 | end | |
| 22 | ||
| 23 | def render("show", %{user: user}) do | |
| 24 | %{ | |
| 25 | 19 | username: user.username, |
| 26 | 19 | full_name: user.full_name, |
| 27 | handles: handles(user), | |
| 28 | url: Routes.api_user_url(Endpoint, :show, user), | |
| 29 | 19 | inserted_at: user.inserted_at, |
| 30 | 19 | updated_at: user.updated_at |
| 31 | } | |
| 32 | |> put_maybe(:email, User.email(user, :public)) | |
| 33 | 19 | |> ViewHelpers.include_if_loaded(:owned_packages, user.owned_packages, &owned_packages/1) |
| 34 | 19 | |> ViewHelpers.include_if_loaded(:packages, user.owned_packages, &packages/1) |
| 35 | end | |
| 36 | ||
| 37 | def render("me", %{user: user}) do | |
| 38 | render_one(user, __MODULE__, "show") | |
| 39 | 1 | |> Map.put(:organizations, organizations(user)) |
| 40 | end | |
| 41 | ||
| 42 | def render("minimal", %{user: user}) do | |
| 43 | %{ | |
| 44 | 23 | username: user.username, |
| 45 | url: Routes.api_user_url(Endpoint, :show, user) | |
| 46 | } | |
| 47 | 23 | |> put_maybe(:email, User.email(user, :public)) |
| 48 | end | |
| 49 | ||
| 50 | def handles(user) do | |
| 51 | 19 | Enum.into(UserHandles.render(user), %{}, fn {field, _service, url} -> |
| 52 | {field, url} | |
| 53 | end) | |
| 54 | end | |
| 55 | ||
| 56 | # TODO: deprecated | |
| 57 | defp owned_packages(packages) do | |
| 58 | 6 | Enum.into(packages, %{}, fn package -> |
| 59 | 7 | {package.name, ViewHelpers.url_for_package(package)} |
| 60 | end) | |
| 61 | end | |
| 62 | ||
| 63 | defp packages(packages) do | |
| 64 | packages | |
| 65 | 7 | |> Enum.sort_by(&[repository_sort(&1), &1.name]) |
| 66 | 6 | |> Enum.map(fn package -> |
| 67 | 7 | %{ |
| 68 | 7 | name: package.name, |
| 69 | repository: repository_name(package), | |
| 70 | url: ViewHelpers.url_for_package(package), | |
| 71 | html_url: ViewHelpers.html_url_for_package(package) | |
| 72 | } | |
| 73 | end) | |
| 74 | end | |
| 75 | ||
| 76 | 4 | defp repository_name(%Package{repository_id: 1}), do: "hexpm" |
| 77 | 3 | defp repository_name(%Package{repository: %Repository{name: name}}), do: name |
| 78 | ||
| 79 | # TODO: DRY up | |
| 80 | # Atoms sort before strings | |
| 81 | 4 | defp repository_sort(%Package{repository_id: 1}), do: :first |
| 82 | 3 | defp repository_sort(%Package{repository: %Repository{name: name}}), do: name |
| 83 | ||
| 84 | defp organizations(user) do | |
| 85 | 1 | Enum.map(user.organization_users, fn ru -> |
| 86 | 1 | %{ |
| 87 | 1 | name: ru.organization.name, |
| 88 | 1 | role: ru.role |
| 89 | } | |
| 90 | end) | |
| 91 | end | |
| 92 | ||
| 93 | 0 | defp put_maybe(map, _key, nil), do: map |
| 94 | 42 | defp put_maybe(map, key, value), do: Map.put(map, key, value) |
| 95 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.BlogView do | |
| 1 | use HexpmWeb, :view | |
| 2 | ||
| 3 | alias Hexpm.Utils | |
| 4 | ||
| 5 | skip_slugs = ~w() | |
| 6 | ||
| 7 | all_templates = | |
| 8 | Phoenix.Template.find_all(@phoenix_root) | |
| 9 | |> Enum.map(&Phoenix.Template.template_path_to_name(&1, @phoenix_root)) | |
| 10 | |> Enum.flat_map(fn | |
| 11 | <<n1, n2, n3, "-", slug::binary>> = template | |
| 12 | when n1 in ?0..?9 and n2 in ?0..?9 and n3 in ?0..?9 -> | |
| 13 | [{Path.rootname(slug), template}] | |
| 14 | ||
| 15 | _other -> | |
| 16 | [] | |
| 17 | end) | |
| 18 | |> Enum.reject(fn {slug, _template} -> slug in skip_slugs end) | |
| 19 | |> Enum.sort_by(&elem(&1, 1), &>=/2) | |
| 20 | ||
| 21 | def render("index.html", _assigns) do | |
| 22 | 0 | render_template("index.html", posts: posts()) |
| 23 | end | |
| 24 | ||
| 25 | def render("index.xml", _assigns) do | |
| 26 | 1 | render_template("index.xml", posts: posts()) |
| 27 | end | |
| 28 | ||
| 29 | def render(other, _assigns) do | |
| 30 | 15 | content_tag(:div, render_template(other, %{}), class: "show-post") |
| 31 | end | |
| 32 | ||
| 33 | 1 | def all_templates() do |
| 34 | unquote(all_templates) | |
| 35 | end | |
| 36 | ||
| 37 | defp posts() do | |
| 38 | 1 | Enum.map(all_templates(), fn {slug, template} -> |
| 39 | 15 | content = render(template, %{}) |
| 40 | 15 | content = Phoenix.HTML.safe_to_string(content) |
| 41 | ||
| 42 | 15 | %{ |
| 43 | slug: slug, | |
| 44 | title: title(content), | |
| 45 | subtitle: subtitle(content), | |
| 46 | paragraph: first_paragraph(content), | |
| 47 | published: published(content) | |
| 48 | } | |
| 49 | end) | |
| 50 | end | |
| 51 | ||
| 52 | defp first_paragraph(content) do | |
| 53 | 15 | regex_run(~r[<p>(.*)</p>]sU, content) |
| 54 | end | |
| 55 | ||
| 56 | defp title(content) do | |
| 57 | 15 | regex_run(~r[<h2>(.*)</h2>]sU, content) |
| 58 | end | |
| 59 | ||
| 60 | defp subtitle(content) do | |
| 61 | 15 | regex_run(~r[<div class="subtitle">(.*)</div>]sU, content) |
| 62 | end | |
| 63 | ||
| 64 | defp published(content) do | |
| 65 | 15 | {:ok, datetime, _utc_offset} = |
| 66 | ~r[<time datetime="(.+)">(.+)</time>]sU | |
| 67 | |> regex_run(content) | |
| 68 | |> DateTime.from_iso8601() | |
| 69 | ||
| 70 | 15 | Utils.datetime_to_rfc2822(datetime) |
| 71 | end | |
| 72 | ||
| 73 | defp regex_run(regex, string) do | |
| 74 | regex | |
| 75 | |> Regex.run(string) | |
| 76 | |> Enum.at(1) | |
| 77 | 60 | |> String.trim() |
| 78 | end | |
| 79 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Dashboard.AuditLogView do | |
| 1 | use HexpmWeb, :view | |
| 2 | ||
| 3 | alias HexpmWeb.DashboardView | |
| 4 | ||
| 5 | @doc """ | |
| 6 | Translate an audit_log to user readable descriptions | |
| 7 | """ | |
| 8 | def humanize_audit_log_info(%AuditLog{action: "docs.publish", params: params}) do | |
| 9 | 2 | "Publish documentation for #{params["package"]["name"]} (#{params["release"]["version"]})" |
| 10 | end | |
| 11 | ||
| 12 | def humanize_audit_log_info(%AuditLog{action: "docs.revert", params: params}) do | |
| 13 | 1 | "Revert documentation for #{params["package"]["name"]} (#{params["release"]["version"]})" |
| 14 | end | |
| 15 | ||
| 16 | def humanize_audit_log_info(%AuditLog{action: "key.generate", params: params}) do | |
| 17 | 1 | "Generate key #{params["name"]}" |
| 18 | end | |
| 19 | ||
| 20 | def humanize_audit_log_info(%AuditLog{action: "key.remove", params: params}) do | |
| 21 | 1 | "Remove key #{params["name"]}" |
| 22 | end | |
| 23 | ||
| 24 | def humanize_audit_log_info(%AuditLog{action: "owner.add", params: params}) do | |
| 25 | 1 | "Add #{params["user"]["username"]} as a new owner of package #{params["package"]["name"]}" |
| 26 | end | |
| 27 | ||
| 28 | def humanize_audit_log_info(%AuditLog{action: "owner.transfer", params: params}) do | |
| 29 | 1 | "Transfer package #{params["package"]["name"]} to #{params["user"]["username"]}" |
| 30 | end | |
| 31 | ||
| 32 | def humanize_audit_log_info(%AuditLog{action: "owner.remove", params: params}) do | |
| 33 | 1 | "Remove #{params["user"]["username"]} from owners of package #{params["package"]["name"]}" |
| 34 | end | |
| 35 | ||
| 36 | def humanize_audit_log_info(%AuditLog{action: "release.publish", params: params}) do | |
| 37 | 1 | "Publish package #{params["package"]["name"]} (#{params["release"]["version"]})" |
| 38 | end | |
| 39 | ||
| 40 | def humanize_audit_log_info(%AuditLog{action: "release.revert", params: params}) do | |
| 41 | 1 | "Revert package #{params["package"]["name"]} (#{params["release"]["version"]})" |
| 42 | end | |
| 43 | ||
| 44 | def humanize_audit_log_info(%AuditLog{action: "release.retire", params: params}) do | |
| 45 | 1 | "Retire package #{params["package"]["name"]} (#{params["release"]["version"]})" |
| 46 | end | |
| 47 | ||
| 48 | def humanize_audit_log_info(%AuditLog{action: "release.unretire", params: params}) do | |
| 49 | 1 | "Unretire package #{params["package"]["name"]} (#{params["release"]["version"]})" |
| 50 | end | |
| 51 | ||
| 52 | def humanize_audit_log_info(%AuditLog{action: "email.add", params: params}) do | |
| 53 | 1 | "Add email #{params["email"]}" |
| 54 | end | |
| 55 | ||
| 56 | def humanize_audit_log_info(%AuditLog{action: "email.remove", params: params}) do | |
| 57 | 1 | "Remove email #{params["email"]}" |
| 58 | end | |
| 59 | ||
| 60 | def humanize_audit_log_info(%AuditLog{action: "email.primary", params: params}) do | |
| 61 | 1 | "Set email #{params["email"]} as primary email" |
| 62 | end | |
| 63 | ||
| 64 | def humanize_audit_log_info(%AuditLog{ | |
| 65 | action: "email.public", | |
| 66 | params: %{"old_email" => old_email, "new_email" => nil} | |
| 67 | }) do | |
| 68 | 1 | "Set email #{old_email["email"]} as private email" |
| 69 | end | |
| 70 | ||
| 71 | def humanize_audit_log_info(%AuditLog{action: "email.public", params: params}) do | |
| 72 | 1 | "Set email #{params["email"]} as public email" |
| 73 | end | |
| 74 | ||
| 75 | def humanize_audit_log_info(%AuditLog{action: "email.gravatar", params: params}) do | |
| 76 | 1 | "Set email #{params["email"]} as gravatar email" |
| 77 | end | |
| 78 | ||
| 79 | 2 | def humanize_audit_log_info(%AuditLog{action: "user.create"}) do |
| 80 | "Create user account" | |
| 81 | end | |
| 82 | ||
| 83 | 1 | def humanize_audit_log_info(%AuditLog{action: "user.update"}) do |
| 84 | "Update user profile" | |
| 85 | end | |
| 86 | ||
| 87 | 1 | def humanize_audit_log_info(%AuditLog{action: "security.update"}) do |
| 88 | "Update TFA settings" | |
| 89 | end | |
| 90 | ||
| 91 | 1 | def humanize_audit_log_info(%AuditLog{action: "security.rotate_recovery_codes"}) do |
| 92 | "Rotate TFA recovery codes" | |
| 93 | end | |
| 94 | ||
| 95 | def humanize_audit_log_info(%AuditLog{action: "organization.create", params: params}) do | |
| 96 | 1 | "Create organization #{params["name"]}" |
| 97 | end | |
| 98 | ||
| 99 | def humanize_audit_log_info(%AuditLog{action: "organization.member.add", params: params}) do | |
| 100 | 1 | "Add user #{params["user"]["username"]} to organization #{params["organization"]["name"]}" |
| 101 | end | |
| 102 | ||
| 103 | def humanize_audit_log_info(%AuditLog{action: "organization.member.remove", params: params}) do | |
| 104 | 1 | "Remove user #{params["user"]["username"]} from organization #{params["organization"]["name"]}" |
| 105 | end | |
| 106 | ||
| 107 | def humanize_audit_log_info(%AuditLog{action: "organization.member.role", params: params}) do | |
| 108 | 1 | "Change user #{params["user"]["username"]}'s role to #{params["role"]} " <> |
| 109 | 1 | "in organization #{params["organization"]["name"]}" |
| 110 | end | |
| 111 | ||
| 112 | 1 | def humanize_audit_log_info(%AuditLog{action: "password.reset.init"}) do |
| 113 | "Request to reset password" | |
| 114 | end | |
| 115 | ||
| 116 | 1 | def humanize_audit_log_info(%AuditLog{action: "password.reset.finish"}) do |
| 117 | "Reset password successfully" | |
| 118 | end | |
| 119 | ||
| 120 | 1 | def humanize_audit_log_info(%AuditLog{action: "password.update"}) do |
| 121 | "Update password" | |
| 122 | end | |
| 123 | ||
| 124 | def humanize_audit_log_info(%AuditLog{action: "billing.checkout", params: params}) do | |
| 125 | 1 | "Update payment method for organization #{params["organization"]["name"]}" |
| 126 | end | |
| 127 | ||
| 128 | def humanize_audit_log_info(%AuditLog{action: "billing.cancel", params: params}) do | |
| 129 | 1 | "Cancel billing on organization #{params["organization"]["name"]}" |
| 130 | end | |
| 131 | ||
| 132 | def humanize_audit_log_info(%AuditLog{action: "billing.create", params: params}) do | |
| 133 | 1 | "Add billing information to organization #{params["organization"]["name"]}" |
| 134 | end | |
| 135 | ||
| 136 | def humanize_audit_log_info(%AuditLog{action: "billing.update", params: params}) do | |
| 137 | 1 | "Update billing information for organization #{params["organization"]["name"]}" |
| 138 | end | |
| 139 | ||
| 140 | def humanize_audit_log_info(%AuditLog{action: "billing.change_plan", params: params}) do | |
| 141 | 2 | "Change billing plan on organization #{params["organization"]["name"]} to " <> |
| 142 | 2 | "#{plan_id(params["plan_id"])}" |
| 143 | end | |
| 144 | ||
| 145 | def humanize_audit_log_info(%AuditLog{action: "billing.pay_invoice", params: params}) do | |
| 146 | 1 | "Manually pay invoice for organization #{params["organization"]["name"]}" |
| 147 | end | |
| 148 | ||
| 149 | 1 | defp plan_id("organization-monthly"), do: "monthly" |
| 150 | 1 | defp plan_id("organization-annually"), do: "annually" |
| 151 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Dashboard.EmailView do | |
| 1 | use HexpmWeb, :view | |
| 2 | alias HexpmWeb.DashboardView | |
| 3 | ||
| 4 | def public_email_options(user) do | |
| 5 | 1 | emails = |
| 6 | 1 | user.emails |
| 7 | |> Email.order_emails() | |
| 8 | 1 | |> Enum.filter(& &1.verified) |
| 9 | 1 | |> Enum.map(&{&1.email, &1.email}) |
| 10 | ||
| 11 | 1 | [{"Don't show a public email address", "none"}] ++ emails |
| 12 | end | |
| 13 | ||
| 14 | def public_email_value(user) do | |
| 15 | 1 | User.email(user, :public) || "none" |
| 16 | end | |
| 17 | ||
| 18 | def gravatar_email_options(user) do | |
| 19 | 2 | emails = |
| 20 | 2 | user.emails |
| 21 | 3 | |> Enum.filter(& &1.verified) |
| 22 | 2 | |> Enum.map(&{&1.email, &1.email}) |
| 23 | ||
| 24 | 2 | [{"Don't show an avatar", "none"}] ++ emails |
| 25 | end | |
| 26 | ||
| 27 | def gravatar_email_value(user) do | |
| 28 | 3 | User.email(user, :gravatar) || "none" |
| 29 | end | |
| 30 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Dashboard.KeyView do | |
| 1 | use HexpmWeb, :view | |
| 2 | alias HexpmWeb.DashboardView | |
| 3 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Dashboard.OrganizationView do | |
| 1 | use HexpmWeb, :view | |
| 2 | alias HexpmWeb.DashboardView | |
| 3 | ||
| 4 | defp organization_roles_selector() do | |
| 5 | 11 | Enum.map(organization_roles(), fn {name, id, _title} -> |
| 6 | {name, id} | |
| 7 | end) | |
| 8 | end | |
| 9 | ||
| 10 | 39 | defp organization_roles() do |
| 11 | [ | |
| 12 | {"Admin", "admin", "This role has full control of the organization"}, | |
| 13 | {"Write", "write", "This role has package owner access to all organization packages"}, | |
| 14 | {"Read", "read", "This role can fetch all organization packages"} | |
| 15 | ] | |
| 16 | end | |
| 17 | ||
| 18 | defp organization_role(id) do | |
| 19 | 14 | Enum.find_value(organization_roles(), fn {name, organization_id, _title} -> |
| 20 | 27 | if id == organization_id do |
| 21 | 14 | name |
| 22 | end | |
| 23 | end) | |
| 24 | end | |
| 25 | ||
| 26 | 1 | defp plan("organization-monthly"), do: "Organization, monthly billed ($7.00 per user / month)" |
| 27 | 0 | defp plan("organization-annually"), do: "Organization, annually billed ($70.00 per user / year)" |
| 28 | 2 | defp plan_price("organization-monthly"), do: "$7.00" |
| 29 | 0 | defp plan_price("organization-annually"), do: "$70.00" |
| 30 | ||
| 31 | defp proration_description("organization-monthly", price, days, quantity, quantity) do | |
| 32 | """ | |
| 33 | Each new seat will be prorated on the next invoice for | |
| 34 | 0 | <strong>#{days}</strong> day(s) @ <strong>$#{money(price)}</strong>. |
| 35 | """ | |
| 36 | 0 | |> raw() |
| 37 | end | |
| 38 | ||
| 39 | defp proration_description("organization-annually", price, days, quantity, quantity) do | |
| 40 | """ | |
| 41 | Each new seat will be charged a proration for | |
| 42 | 0 | <strong>#{days}</strong> day(s) @ <strong>$#{money(price)}</strong>. |
| 43 | """ | |
| 44 | 0 | |> raw() |
| 45 | end | |
| 46 | ||
| 47 | defp proration_description("organization-monthly", price, days, quantity, max_period_quantity) | |
| 48 | when quantity < max_period_quantity do | |
| 49 | """ | |
| 50 | 0 | You have already used <strong>#{max_period_quantity}</strong> seats in your current billing period. |
| 51 | If adding seats over this amount, each new seat will be prorated on the next invoice for | |
| 52 | 0 | <strong>#{days}</strong> day(s) @ <strong>$#{money(price)}</strong>. |
| 53 | """ | |
| 54 | 0 | |> raw() |
| 55 | end | |
| 56 | ||
| 57 | defp proration_description("organization-annually", price, days, quantity, max_period_quantity) | |
| 58 | when quantity < max_period_quantity do | |
| 59 | """ | |
| 60 | 0 | You have already used <strong>#{max_period_quantity}</strong> seats in your current billing period. |
| 61 | If adding seats over this amount, each new seat will be charged a proration for | |
| 62 | 0 | <strong>#{days}</strong> day(s) @ <strong>$#{money(price)}</strong>. |
| 63 | """ | |
| 64 | 0 | |> raw() |
| 65 | end | |
| 66 | ||
| 67 | @no_card_message "No payment method on file" | |
| 68 | ||
| 69 | 2 | defp payment_card(nil) do |
| 70 | @no_card_message | |
| 71 | end | |
| 72 | ||
| 73 | 0 | defp payment_card(%{"brand" => nil}) do |
| 74 | @no_card_message | |
| 75 | end | |
| 76 | ||
| 77 | defp payment_card(card) do | |
| 78 | 0 | card_exp_month = String.pad_leading(card["exp_month"], 2, "0") |
| 79 | 0 | expires = "#{card_exp_month}/#{card["exp_year"]}" |
| 80 | 0 | "#{card["brand"]} **** **** **** #{card["last4"]}, Expires: #{expires}" |
| 81 | end | |
| 82 | ||
| 83 | 1 | defp subscription_status(%{"status" => "active", "cancel_at_period_end" => false}, _card) do |
| 84 | "Active" | |
| 85 | end | |
| 86 | ||
| 87 | 0 | defp subscription_status(%{"status" => "active", "cancel_at_period_end" => true}, _card) do |
| 88 | "Ends after current subscription period" | |
| 89 | end | |
| 90 | ||
| 91 | defp subscription_status( | |
| 92 | %{"status" => "trialing", "trial_end" => trial_end}, | |
| 93 | card | |
| 94 | ) do | |
| 95 | 0 | trial_end = trial_end |> NaiveDateTime.from_iso8601!() |> ViewHelpers.pretty_date() |
| 96 | 0 | raw("Trial ends on #{trial_end}, #{trial_status_message(card)}") |
| 97 | end | |
| 98 | ||
| 99 | defp subscription_status(%{"status" => "past_due"}, _card) do | |
| 100 | 0 | "Active with past due invoice, if the invoice is not paid the " <> |
| 101 | "organization will be disabled" | |
| 102 | end | |
| 103 | ||
| 104 | 0 | defp subscription_status(%{"status" => "incomplete"}, _card) do |
| 105 | "TODO" | |
| 106 | end | |
| 107 | ||
| 108 | # TODO: Check if last invoice was unpaid and add note about it? | |
| 109 | 0 | defp subscription_status(%{"status" => "canceled"}, _card) do |
| 110 | "Not active" | |
| 111 | end | |
| 112 | ||
| 113 | @trial_ends_no_card_message """ | |
| 114 | your subscription will end after the trial period because we have no payment method on file for you, | |
| 115 | please enter a payment method if you wish to continue using organizations after the trial period | |
| 116 | """ | |
| 117 | ||
| 118 | 0 | defp trial_status_message(%{"brand" => nil}) do |
| 119 | @trial_ends_no_card_message | |
| 120 | end | |
| 121 | ||
| 122 | 0 | defp trial_status_message(nil) do |
| 123 | @trial_ends_no_card_message | |
| 124 | end | |
| 125 | ||
| 126 | 0 | defp trial_status_message(_card) do |
| 127 | "a payment method is on file and your subscription will continue after the trial period" | |
| 128 | end | |
| 129 | ||
| 130 | 1 | defp discount_status(nil) do |
| 131 | "" | |
| 132 | end | |
| 133 | ||
| 134 | defp discount_status(%{"name" => name, "percent_off" => percent_off}) do | |
| 135 | 0 | "(\"#{name}\" discount for #{percent_off}% of price)" |
| 136 | end | |
| 137 | ||
| 138 | 1 | defp invoice_status(%{"paid" => true}, _organization, _card), do: "Paid" |
| 139 | 0 | defp invoice_status(%{"status" => "uncollectible"}, _organization, _card), do: "Forgiven" |
| 140 | ||
| 141 | 0 | defp invoice_status(%{"paid" => false, "attempted" => false}, _organization, _card), |
| 142 | do: "Pending" | |
| 143 | ||
| 144 | defp invoice_status(%{"paid" => false, "attempted" => true}, _organization, nil = _card) do | |
| 145 | 0 | submit( |
| 146 | "Pay now", | |
| 147 | class: "btn btn-primary", | |
| 148 | disabled: true, | |
| 149 | title: "No payment method on file" | |
| 150 | ) | |
| 151 | end | |
| 152 | ||
| 153 | defp invoice_status( | |
| 154 | %{"paid" => false, "attempted" => true, "id" => invoice_id}, | |
| 155 | organization, | |
| 156 | _card | |
| 157 | ) do | |
| 158 | 0 | form_tag(Routes.organization_path(Endpoint, :pay_invoice, organization, invoice_id)) do |
| 159 | submit("Pay now", class: "btn btn-primary") | |
| 160 | end | |
| 161 | end | |
| 162 | ||
| 163 | def payment_date(iso_8601_datetime_string) do | |
| 164 | 5 | iso_8601_datetime_string |> NaiveDateTime.from_iso8601!() |> ViewHelpers.pretty_date() |
| 165 | end | |
| 166 | ||
| 167 | defp money(integer) when is_integer(integer) and integer >= 0 do | |
| 168 | 2 | whole = div(integer, 100) |
| 169 | 2 | float = rem(integer, 100) |> Integer.to_string() |> String.pad_leading(2, "0") |
| 170 | 2 | "#{whole}.#{float}" |
| 171 | end | |
| 172 | ||
| 173 | defp default_billing_emails(user, billing_email) do | |
| 174 | 14 | emails = |
| 175 | 14 | user.emails |
| 176 | 14 | |> Enum.filter(& &1.verified) |
| 177 | 14 | |> Enum.map(& &1.email) |
| 178 | ||
| 179 | [billing_email | emails] | |
| 180 | 28 | |> Enum.reject(&is_nil/1) |
| 181 | 14 | |> Enum.uniq() |
| 182 | end | |
| 183 | ||
| 184 | # From Hexpm.Billing.Country | |
| 185 | @country_codes [ | |
| 186 | {"AD", "Andorra"}, | |
| 187 | {"AE", "United Arab Emirates"}, | |
| 188 | {"AF", "Afghanistan"}, | |
| 189 | {"AG", "Antigua and Barbuda"}, | |
| 190 | {"AI", "Anguilla"}, | |
| 191 | {"AL", "Albania"}, | |
| 192 | {"AM", "Armenia"}, | |
| 193 | {"AO", "Angola"}, | |
| 194 | {"AQ", "Antarctica"}, | |
| 195 | {"AR", "Argentina"}, | |
| 196 | {"AS", "American Samoa"}, | |
| 197 | {"AT", "Austria"}, | |
| 198 | {"AU", "Australia"}, | |
| 199 | {"AW", "Aruba"}, | |
| 200 | {"AX", "Ã…land Islands"}, | |
| 201 | {"AZ", "Azerbaijan"}, | |
| 202 | {"BA", "Bosnia and Herzegovina"}, | |
| 203 | {"BB", "Barbados"}, | |
| 204 | {"BD", "Bangladesh"}, | |
| 205 | {"BE", "Belgium"}, | |
| 206 | {"BF", "Burkina Faso"}, | |
| 207 | {"BG", "Bulgaria"}, | |
| 208 | {"BH", "Bahrain"}, | |
| 209 | {"BI", "Burundi"}, | |
| 210 | {"BJ", "Benin"}, | |
| 211 | {"BL", "Saint Barthélemy"}, | |
| 212 | {"BM", "Bermuda"}, | |
| 213 | # Brunei Darussalam | |
| 214 | {"BN", "Brunei"}, | |
| 215 | # Bolivia, Plurinational State | |
| 216 | {"BO", "Bolivia"}, | |
| 217 | # Bonaire, Sint Eustatius and Saba | |
| 218 | {"BQ", "Bonaire"}, | |
| 219 | {"BR", "Brazil"}, | |
| 220 | {"BS", "Bahamas"}, | |
| 221 | {"BT", "Bhutan"}, | |
| 222 | {"BV", "Bouvet Island"}, | |
| 223 | {"BW", "Botswana"}, | |
| 224 | {"BY", "Belarus"}, | |
| 225 | {"BZ", "Belize"}, | |
| 226 | {"CA", "Canada"}, | |
| 227 | {"CC", "Cocos (Keeling) Islands"}, | |
| 228 | {"CD", "Congo, the Democratic Republic of the"}, | |
| 229 | {"CF", "Central African Republic"}, | |
| 230 | {"CG", "Congo"}, | |
| 231 | {"CH", "Switzerland"}, | |
| 232 | {"CI", "Côte d'Ivoire"}, | |
| 233 | {"CK", "Cook Islands"}, | |
| 234 | {"CL", "Chile"}, | |
| 235 | {"CM", "Cameroon"}, | |
| 236 | {"CN", "China"}, | |
| 237 | {"CO", "Colombia"}, | |
| 238 | {"CR", "Costa Rica"}, | |
| 239 | {"CU", "Cuba"}, | |
| 240 | {"CV", "Cabo Verde"}, | |
| 241 | {"CW", "Curaçao"}, | |
| 242 | {"CX", "Christmas Island"}, | |
| 243 | {"CY", "Cyprus"}, | |
| 244 | # Czechia (Changed for Stripe compatibility) | |
| 245 | {"CZ", "Czech Republic"}, | |
| 246 | {"DE", "Germany"}, | |
| 247 | {"DJ", "Djibouti"}, | |
| 248 | {"DK", "Denmark"}, | |
| 249 | {"DM", "Dominica"}, | |
| 250 | {"DO", "Dominican Republic"}, | |
| 251 | {"DZ", "Algeria"}, | |
| 252 | {"EC", "Ecuador"}, | |
| 253 | {"EE", "Estonia"}, | |
| 254 | {"EG", "Egypt"}, | |
| 255 | {"EH", "Western Sahara"}, | |
| 256 | {"ER", "Eritrea"}, | |
| 257 | {"ES", "Spain"}, | |
| 258 | {"ET", "Ethiopia"}, | |
| 259 | {"FI", "Finland"}, | |
| 260 | {"FJ", "Fiji"}, | |
| 261 | # Falkland Islands (Malvinas) | |
| 262 | {"FK", "Falkland Island"}, | |
| 263 | # Micronesia, Federated States of | |
| 264 | {"FM", "Micronesia"}, | |
| 265 | {"FO", "Faroe Islands"}, | |
| 266 | {"FR", "France"}, | |
| 267 | {"GA", "Gabon"}, | |
| 268 | # United Kingdom of Great Britain and Northern Ireland | |
| 269 | {"GB", "United Kingdom"}, | |
| 270 | {"GD", "Grenada"}, | |
| 271 | {"GE", "Georgia"}, | |
| 272 | {"GF", "French Guiana"}, | |
| 273 | {"GG", "Guernsey"}, | |
| 274 | {"GH", "Ghana"}, | |
| 275 | {"GI", "Gibraltar"}, | |
| 276 | {"GL", "Greenland"}, | |
| 277 | {"GM", "Gambia"}, | |
| 278 | {"GN", "Guinea"}, | |
| 279 | {"GP", "Guadeloupe"}, | |
| 280 | {"GQ", "Equatorial Guinea"}, | |
| 281 | {"GR", "Greece"}, | |
| 282 | # South Georgia and the South Sandwich Islands | |
| 283 | {"GS", "South Georgia"}, | |
| 284 | {"GT", "Guatemala"}, | |
| 285 | {"GU", "Guam"}, | |
| 286 | {"GW", "Guinea-Bissau"}, | |
| 287 | {"GY", "Guyana"}, | |
| 288 | {"HK", "Hong Kong"}, | |
| 289 | {"HM", "Heard Island and McDonald Islands"}, | |
| 290 | {"HN", "Honduras"}, | |
| 291 | {"HR", "Croatia"}, | |
| 292 | {"HT", "Haiti"}, | |
| 293 | {"HU", "Hungary"}, | |
| 294 | {"ID", "Indonesia"}, | |
| 295 | {"IE", "Ireland"}, | |
| 296 | {"IL", "Israel"}, | |
| 297 | {"IM", "Isle of Man"}, | |
| 298 | {"IN", "India"}, | |
| 299 | {"IO", "British Indian Ocean Territory"}, | |
| 300 | {"IQ", "Iraq"}, | |
| 301 | # Iran, Islamic Republic | |
| 302 | {"IR", "Iran"}, | |
| 303 | {"IS", "Iceland"}, | |
| 304 | {"IT", "Italy"}, | |
| 305 | {"JE", "Jersey"}, | |
| 306 | {"JM", "Jamaica"}, | |
| 307 | {"JO", "Jordan"}, | |
| 308 | {"JP", "Japan"}, | |
| 309 | {"KE", "Kenya"}, | |
| 310 | {"KG", "Kyrgyzstan"}, | |
| 311 | {"KH", "Cambodia"}, | |
| 312 | {"KI", "Kiribati"}, | |
| 313 | {"KM", "Comoros"}, | |
| 314 | {"KN", "Saint Kitts and Nevis"}, | |
| 315 | {"KP", "Korea, Democratic People's Republic of"}, | |
| 316 | {"KR", "Korea, Republic of"}, | |
| 317 | {"KW", "Kuwait"}, | |
| 318 | {"KY", "Cayman Islands"}, | |
| 319 | {"KZ", "Kazakhstan"}, | |
| 320 | # Lao People's Democratic Republic | |
| 321 | {"LA", "Laos"}, | |
| 322 | {"LB", "Lebanon"}, | |
| 323 | {"LC", "Saint Lucia"}, | |
| 324 | {"LI", "Liechtenstein"}, | |
| 325 | {"LK", "Sri Lanka"}, | |
| 326 | {"LR", "Liberia"}, | |
| 327 | {"LS", "Lesotho"}, | |
| 328 | {"LT", "Lithuania"}, | |
| 329 | {"LU", "Luxembourg"}, | |
| 330 | {"LV", "Latvia"}, | |
| 331 | {"LY", "Libya"}, | |
| 332 | {"MA", "Morocco"}, | |
| 333 | {"MC", "Monaco"}, | |
| 334 | {"MD", "Moldova , Republic"}, | |
| 335 | {"ME", "Montenegro"}, | |
| 336 | # Saint Martin (French part) | |
| 337 | {"MF", "Saint Martin"}, | |
| 338 | {"MG", "Madagascar"}, | |
| 339 | {"MH", "Marshall Islands"}, | |
| 340 | {"MK", "Macedonia"}, | |
| 341 | {"ML", "Mali"}, | |
| 342 | {"MM", "Myanmar"}, | |
| 343 | {"MN", "Mongolia"}, | |
| 344 | {"MO", "Macao"}, | |
| 345 | {"MP", "Northern Mariana Islands"}, | |
| 346 | {"MQ", "Martinique"}, | |
| 347 | {"MR", "Mauritania"}, | |
| 348 | {"MS", "Montserrat"}, | |
| 349 | {"MT", "Malta"}, | |
| 350 | {"MU", "Mauritius"}, | |
| 351 | {"MV", "Maldives"}, | |
| 352 | {"MW", "Malawi"}, | |
| 353 | {"MX", "Mexico"}, | |
| 354 | {"MY", "Malaysia"}, | |
| 355 | {"MZ", "Mozambique"}, | |
| 356 | {"NA", "Namibia"}, | |
| 357 | {"NC", "New Caledonia"}, | |
| 358 | {"NE", "Niger"}, | |
| 359 | {"NF", "Norfolk Island"}, | |
| 360 | {"NG", "Nigeria"}, | |
| 361 | {"NI", "Nicaragua"}, | |
| 362 | {"NL", "Netherlands"}, | |
| 363 | {"NO", "Norway"}, | |
| 364 | {"NP", "Nepal"}, | |
| 365 | {"NR", "Nauru"}, | |
| 366 | {"NU", "Niue"}, | |
| 367 | {"NZ", "New Zealand"}, | |
| 368 | {"OM", "Oman"}, | |
| 369 | {"PA", "Panama"}, | |
| 370 | {"PE", "Peru"}, | |
| 371 | {"PF", "French Polynesia"}, | |
| 372 | {"PG", "Papua New Guinea"}, | |
| 373 | {"PH", "Philippines"}, | |
| 374 | {"PK", "Pakistan"}, | |
| 375 | {"PL", "Poland"}, | |
| 376 | {"PM", "Saint Pierre and Miquelon"}, | |
| 377 | {"PN", "Pitcairn"}, | |
| 378 | {"PR", "Puerto Rico"}, | |
| 379 | # Palestine, State of | |
| 380 | {"PS", "Palestin"}, | |
| 381 | {"PT", "Portugal"}, | |
| 382 | {"PW", "Palau"}, | |
| 383 | {"PY", "Paraguay"}, | |
| 384 | {"QA", "Qatar"}, | |
| 385 | {"RE", "Réunion"}, | |
| 386 | {"RO", "Romania"}, | |
| 387 | {"RS", "Serbia"}, | |
| 388 | # Russian Federation | |
| 389 | {"RU", "Russia"}, | |
| 390 | {"RW", "Rwanda"}, | |
| 391 | {"SA", "Saudi Arabia"}, | |
| 392 | {"SB", "Solomon Islands"}, | |
| 393 | {"SC", "Seychelles"}, | |
| 394 | {"SD", "Sudan"}, | |
| 395 | {"SE", "Sweden"}, | |
| 396 | {"SG", "Singapore"}, | |
| 397 | {"SH", "Saint Helena, Ascension and Tristan da Cunha"}, | |
| 398 | {"SI", "Slovenia"}, | |
| 399 | {"SJ", "Svalbard and Jan Mayen"}, | |
| 400 | {"SK", "Slovakia"}, | |
| 401 | {"SL", "Sierra Leone"}, | |
| 402 | {"SM", "San Marino"}, | |
| 403 | {"SN", "Senegal"}, | |
| 404 | {"SO", "Somalia"}, | |
| 405 | {"SR", "Suriname"}, | |
| 406 | {"SS", "South Sudan"}, | |
| 407 | {"ST", "Sao Tome and Principe"}, | |
| 408 | {"SV", "El Salvador"}, | |
| 409 | # Sint Maarten (Dutch part) | |
| 410 | {"SX", "Sint Maarten"}, | |
| 411 | # Syrian Arab Republic | |
| 412 | {"SY", "Syria"}, | |
| 413 | {"SZ", "Swaziland"}, | |
| 414 | {"TC", "Turks and Caicos Islands"}, | |
| 415 | {"TD", "Chad"}, | |
| 416 | {"TF", "French Southern Territories"}, | |
| 417 | {"TG", "Togo"}, | |
| 418 | {"TH", "Thailand"}, | |
| 419 | {"TJ", "Tajikistan"}, | |
| 420 | {"TK", "Tokelau"}, | |
| 421 | {"TL", "Timor-Leste"}, | |
| 422 | {"TM", "Turkmenistan"}, | |
| 423 | {"TN", "Tunisia"}, | |
| 424 | {"TO", "Tonga"}, | |
| 425 | {"TR", "Turkey"}, | |
| 426 | {"TT", "Trinidad and Tobago"}, | |
| 427 | {"TV", "Tuvalu"}, | |
| 428 | # Taiwan, Province of China | |
| 429 | {"TW", "Taiwan"}, | |
| 430 | # Tanzania, United Republic of | |
| 431 | {"TZ", "Tanzania"}, | |
| 432 | {"UA", "Ukraine"}, | |
| 433 | {"UG", "Uganda"}, | |
| 434 | {"UM", "United States Minor Outlying Islands"}, | |
| 435 | # United States of America | |
| 436 | {"US", "United States"}, | |
| 437 | {"UY", "Uruguay"}, | |
| 438 | {"UZ", "Uzbekistan"}, | |
| 439 | {"VA", "Holy See"}, | |
| 440 | {"VC", "Saint Vincent and the Grenadines"}, | |
| 441 | # Venezuela, Bolivarian Republic of | |
| 442 | {"VE", "Venezuela"}, | |
| 443 | # Virgin Islands, British | |
| 444 | {"VG", "British Virgin Islands"}, | |
| 445 | # Virgin Islands, U.S. | |
| 446 | {"VI", "United States Virgin Islands"}, | |
| 447 | {"VN", "Viet Nam"}, | |
| 448 | {"VU", "Vanuatu"}, | |
| 449 | {"WF", "Wallis and Futuna"}, | |
| 450 | {"WS", "Samoa"}, | |
| 451 | {"YE", "Yemen"}, | |
| 452 | {"YT", "Mayotte"}, | |
| 453 | {"ZA", "South Africa"}, | |
| 454 | {"ZM", "Zambia"}, | |
| 455 | {"ZW", "Zimbabwe"} | |
| 456 | ] | |
| 457 | ||
| 458 | 14 | defp countries() do |
| 459 | unquote([{"", ""}] ++ Enum.sort_by(@country_codes, &elem(&1, 1))) | |
| 460 | end | |
| 461 | ||
| 462 | defp show_person?(person, errors) do | |
| 463 | 14 | (person || errors["person"]) && !errors["company"] |
| 464 | end | |
| 465 | ||
| 466 | defp show_company?(company, errors) do | |
| 467 | 14 | (company || errors["company"]) && !errors["person"] |
| 468 | end | |
| 469 | ||
| 470 | defp organization_admin?(current_user, organization) do | |
| 471 | 11 | user = Enum.find(organization.organization_users, &(&1.user_id == current_user.id)) |
| 472 | 11 | user.role == "admin" |
| 473 | end | |
| 474 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Dashboard.PasswordView do | |
| 1 | use HexpmWeb, :view | |
| 2 | alias HexpmWeb.DashboardView | |
| 3 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Dashboard.ProfileView do | |
| 1 | use HexpmWeb, :view | |
| 2 | alias HexpmWeb.DashboardView | |
| 3 | ||
| 4 | import HexpmWeb.Dashboard.EmailView, | |
| 5 | only: [ | |
| 6 | public_email_options: 1, | |
| 7 | public_email_value: 1, | |
| 8 | gravatar_email_options: 1, | |
| 9 | gravatar_email_value: 1 | |
| 10 | ] | |
| 11 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Dashboard.SecurityView do | |
| 1 | use HexpmWeb, :view | |
| 2 | alias HexpmWeb.DashboardView | |
| 3 | alias Hexpm.Accounts.User | |
| 4 | ||
| 5 | defp show_recovery_codes?(user) do | |
| 6 | 1 | User.tfa_enabled?(user) && user.tfa.recovery_codes |
| 7 | end | |
| 8 | ||
| 9 | defp class_for_code(code) do | |
| 10 | 2 | case code.used_at do |
| 11 | 1 | nil -> "recovery-code-unused" |
| 12 | 1 | _ -> "recovery-code-used" |
| 13 | end | |
| 14 | end | |
| 15 | ||
| 16 | defp aggregate_recovery_codes(codes) do | |
| 17 | 2 | Enum.map(codes, & &1.code) |
| 18 | 1 | |> Enum.reduce(fn code, acc -> acc <> "\n" <> code end) |
| 19 | end | |
| 20 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.Dashboard.TFAAuthSetupView do | |
| 1 | use HexpmWeb, :view | |
| 2 | alias HexpmWeb.DashboardView | |
| 3 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.DashboardView do | |
| 1 | use HexpmWeb, :view | |
| 2 | ||
| 3 | 26 | defp account_settings() do |
| 4 | [ | |
| 5 | profile: {"Profile", Routes.profile_path(Endpoint, :index)}, | |
| 6 | password: {"Password", Routes.dashboard_password_path(Endpoint, :index)}, | |
| 7 | security: {"Security", Routes.dashboard_security_path(Endpoint, :index)}, | |
| 8 | email: {"Emails", Routes.email_path(Endpoint, :index)}, | |
| 9 | keys: {"Keys", Routes.key_path(Endpoint, :index)}, | |
| 10 | audit_logs: {"Recent activities", Routes.audit_log_path(Endpoint, :index)} | |
| 11 | ] | |
| 12 | end | |
| 13 | ||
| 14 | defp selected_setting(conn, id) do | |
| 15 | 156 | if Enum.take(conn.path_info, -2) == ["dashboard", Atom.to_string(id)] do |
| 16 | "selected" | |
| 17 | end | |
| 18 | end | |
| 19 | ||
| 20 | defp selected_organization(conn, name) do | |
| 21 | 11 | if Enum.take(conn.path_info, -2) == ["orgs", name] do |
| 22 | "selected" | |
| 23 | end | |
| 24 | end | |
| 25 | ||
| 26 | 0 | defp permission_name(%KeyPermission{domain: "api", resource: nil}), |
| 27 | do: "API" | |
| 28 | ||
| 29 | defp permission_name(%KeyPermission{domain: "api", resource: resource}), | |
| 30 | 0 | do: "API:#{resource}" |
| 31 | ||
| 32 | defp permission_name(%KeyPermission{domain: "repository", resource: resource}), | |
| 33 | 0 | do: "REPO:#{resource}" |
| 34 | ||
| 35 | 0 | defp permission_name(%KeyPermission{domain: "repositories"}), |
| 36 | do: "REPOS" | |
| 37 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.DocsView do | |
| 1 | use HexpmWeb, :view | |
| 2 | alias HexpmWeb.DocsView | |
| 3 | ||
| 4 | def selected_docs(conn, view) do | |
| 5 | 0 | if conn.assigns.view_name == view do |
| 6 | "selected" | |
| 7 | else | |
| 8 | "" | |
| 9 | end | |
| 10 | end | |
| 11 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.EmailVerificationView do | |
| 1 | use HexpmWeb, :view | |
| 2 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.EmailView do | |
| 1 | use HexpmWeb, :view | |
| 2 | ||
| 3 | defmodule OwnerAdd do | |
| 4 | def message(username, package) do | |
| 5 | 22 | "#{username} has been added as an owner to package #{package}." |
| 6 | end | |
| 7 | end | |
| 8 | ||
| 9 | defmodule OwnerRemove do | |
| 10 | def message(username, package) do | |
| 11 | 8 | "#{username} has been removed from owners of package #{package}." |
| 12 | end | |
| 13 | end | |
| 14 | ||
| 15 | defmodule Verification do | |
| 16 | 50 | def intro() do |
| 17 | "To begin using your email, we require you to verify your email address." | |
| 18 | end | |
| 19 | end | |
| 20 | ||
| 21 | defmodule PasswordResetRequest do | |
| 22 | 22 | def title() do |
| 23 | "Reset your Hex.pm password" | |
| 24 | end | |
| 25 | ||
| 26 | 22 | def message() do |
| 27 | "We heard you've lost your password to Hex.pm. Sorry about that!" | |
| 28 | end | |
| 29 | ||
| 30 | 22 | def mix_code() do |
| 31 | "mix hex.user auth" | |
| 32 | end | |
| 33 | ||
| 34 | 22 | def rebar_code() do |
| 35 | "rebar3 hex user auth" | |
| 36 | end | |
| 37 | ||
| 38 | 22 | def before_code() do |
| 39 | "Once this is complete, your existing keys may be invalidated, you will need to regenerate them by running:" | |
| 40 | end | |
| 41 | ||
| 42 | 22 | def after_code() do |
| 43 | "and entering your username and password." | |
| 44 | end | |
| 45 | end | |
| 46 | ||
| 47 | defmodule PasswordChanged do | |
| 48 | def greeting(username) do | |
| 49 | 4 | "Hello #{username}" |
| 50 | end | |
| 51 | ||
| 52 | 4 | def title() do |
| 53 | "Your password on Hex.pm has been changed." | |
| 54 | end | |
| 55 | end | |
| 56 | ||
| 57 | defmodule TyposquatCandidates do | |
| 58 | def intro(threshold) do | |
| 59 | 0 | """ |
| 60 | 0 | Using Levenshtein Distance with a threshold of #{threshold} |
| 61 | -------------------- | |
| 62 | new_package,current_package,distance | |
| 63 | """ | |
| 64 | end | |
| 65 | ||
| 66 | def table(candidates) do | |
| 67 | candidates | |
| 68 | 0 | |> Enum.map(fn [n, c, d] -> "#{n},#{c},#{d}" end) |
| 69 | 0 | |> Enum.join("\n") |
| 70 | end | |
| 71 | end | |
| 72 | ||
| 73 | defmodule OrganizationInvite do | |
| 74 | 6 | def access_organization() do |
| 75 | "You can access organization packages after authenticating in your shell:" | |
| 76 | end | |
| 77 | ||
| 78 | 6 | def mix_code() do |
| 79 | "mix hex.user auth" | |
| 80 | end | |
| 81 | ||
| 82 | 6 | def rebar_code() do |
| 83 | "rebar3 hex user auth" | |
| 84 | end | |
| 85 | end | |
| 86 | ||
| 87 | defmodule PackagePublished do | |
| 88 | def intro(nil, package, version) do | |
| 89 | 6 | """ |
| 90 | 6 | Package #{package} v#{version} was recently published. |
| 91 | If this wasn't done by you or one of the other package owners, you should | |
| 92 | reset your account and revert or retire the version. | |
| 93 | """ | |
| 94 | end | |
| 95 | ||
| 96 | def intro(publisher, package, version) do | |
| 97 | 50 | """ |
| 98 | 50 | Package #{package} v#{version} was recently published by #{publisher.username}. |
| 99 | If this wasn't done by you or one of the other package owners, you should | |
| 100 | reset your account and revert or retire the version. | |
| 101 | """ | |
| 102 | end | |
| 103 | ||
| 104 | def mix_code(package, version) do | |
| 105 | 56 | """ |
| 106 | 56 | cd #{package}; mix hex.publish --revert #{version} |
| 107 | # or | |
| 108 | 56 | mix hex.retire #{package} #{version} security --message "Not published by owners" |
| 109 | """ | |
| 110 | end | |
| 111 | ||
| 112 | def rebar3_code(package, version) do | |
| 113 | 56 | """ |
| 114 | 56 | cd #{package}; rebar3 hex publish --revert #{version} |
| 115 | # or | |
| 116 | 56 | rebar3 hex retire #{package} #{version} security --message "Not published by owners" |
| 117 | """ | |
| 118 | end | |
| 119 | end | |
| 120 | ||
| 121 | defmodule ReportState do | |
| 122 | 14 | def state_explain("to_accept") do |
| 123 | """ | |
| 124 | The report has now state \"to_accept\". | |
| 125 | This means that the vulnerability reported has to be reviewed by a moderator in order to be recognized or not as a real vulnerability. | |
| 126 | Only the report author and moderators can see the report description. | |
| 127 | """ | |
| 128 | end | |
| 129 | ||
| 130 | 30 | def state_explain("accepted") do |
| 131 | """ | |
| 132 | The report has now state \"accepted\". | |
| 133 | This means that the vulnerability reported has been recognized by a moderator as real. | |
| 134 | A comments section has been enabled on the report for moderators, owners and the report author to discuss the vulnerability. | |
| 135 | """ | |
| 136 | end | |
| 137 | ||
| 138 | 12 | def state_explain("solved") do |
| 139 | """ | |
| 140 | The report has now state \"solved\". | |
| 141 | This means that the vulnerability reported has been solved. | |
| 142 | Now the report is public, so users other than the report author, moderators and the reported package owners can read the report description. | |
| 143 | """ | |
| 144 | end | |
| 145 | ||
| 146 | 8 | def state_explain("rejected") do |
| 147 | """ | |
| 148 | The report has now state \"rejected\". | |
| 149 | This means that the vulnerability reported has not been recognized as such a vulnerability by a moderator. | |
| 150 | The report will not be made public, so users other than the report author and moderators will not be able to read the report description or the comments section. | |
| 151 | Moderators and the report author can still comment about the report on the report's comment section. | |
| 152 | """ | |
| 153 | end | |
| 154 | ||
| 155 | 8 | def state_explain("unresolved") do |
| 156 | """ | |
| 157 | The report has now state \"unresolved\". | |
| 158 | This means the report has been on a revision state (\"accepted\") for too long. | |
| 159 | Now the report is public, so users other than the report author, moderators and the reported package owners can read the report description. | |
| 160 | """ | |
| 161 | end | |
| 162 | end | |
| 163 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.EmailsView do | |
| 1 | use HexpmWeb, :view | |
| 2 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.ErrorView do | |
| 1 | use HexpmWeb, :view | |
| 2 | ||
| 3 | def render(<<status::binary-3>> <> ".html", assigns) when status != "all" do | |
| 4 | 24 | render( |
| 5 | "all.html", | |
| 6 | 24 | conn: assigns.conn, |
| 7 | error: true, | |
| 8 | status: status, | |
| 9 | message: message(status), | |
| 10 | container: "container error-view", | |
| 11 | current_user: assigns[:current_user] | |
| 12 | ) | |
| 13 | end | |
| 14 | ||
| 15 | def render(<<status::binary-3>> <> _, assigns) when status != "all" do | |
| 16 | assigns | |
| 17 | |> Map.take([:message, :errors]) | |
| 18 | |> Map.put(:status, String.to_integer(status)) | |
| 19 | 181 | |> Map.put_new(:message, message(status)) |
| 20 | end | |
| 21 | ||
| 22 | # In case no render clause matches or no | |
| 23 | # template is found, let's render it as a 500 | |
| 24 | def template_not_found(_template, assigns) do | |
| 25 | 0 | render( |
| 26 | "all.html", | |
| 27 | 0 | conn: assigns.conn, |
| 28 | error: true, | |
| 29 | status: "500", | |
| 30 | message: "Internal server error", | |
| 31 | current_user: assigns[:current_user] | |
| 32 | ) | |
| 33 | end | |
| 34 | ||
| 35 | 4 | defp message("400"), do: "Bad request" |
| 36 | 115 | defp message("404"), do: "Page not found" |
| 37 | 1 | defp message("408"), do: "Request timeout" |
| 38 | 1 | defp message("413"), do: "Payload too large" |
| 39 | 1 | defp message("415"), do: "Unsupported media type" |
| 40 | 26 | defp message("422"), do: "Validation error(s)" |
| 41 | 2 | defp message("500"), do: "Internal server error" |
| 42 | 55 | defp message(_), do: nil |
| 43 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.ViewIcons do | |
| 1 | use Phoenix.HTML | |
| 2 | import SweetXml | |
| 3 | ||
| 4 | @icons_dir Path.join(__DIR__, "../../../assets/vendor/icons") | |
| 5 | @octicons_svg Path.join(@icons_dir, "octicons.svg") | |
| 6 | @glyphicons_svg Path.join(@icons_dir, "glyphicons-halflings-regular.svg") | |
| 7 | @glyphicons_less Path.join(@icons_dir, "glyphicons.less") | |
| 8 | ||
| 9 | @external_resource @octicons_svg | |
| 10 | @external_resource @glyphicons_svg | |
| 11 | @external_resource @glyphicons_less | |
| 12 | ||
| 13 | :ok = Application.ensure_loaded(:xmerl) | |
| 14 | {:ok, xmerl_version} = :application.get_key(:xmerl, :vsn) | |
| 15 | ||
| 16 | xmerl_version = | |
| 17 | xmerl_version | |
| 18 | |> List.to_string() | |
| 19 | |> String.split(".") | |
| 20 | |> Enum.concat(["0"]) | |
| 21 | |> Enum.take(3) | |
| 22 | |> Enum.join(".") | |
| 23 | ||
| 24 | broken_xmerl? = Version.compare(xmerl_version, "1.3.20") == :lt | |
| 25 | ||
| 26 | doc = File.read!(@octicons_svg) | |
| 27 | ||
| 28 | octicons = | |
| 29 | SweetXml.xpath( | |
| 30 | doc, | |
| 31 | ~x"//glyph"l, | |
| 32 | name: ~x"./@glyph-name"s, | |
| 33 | d: ~x"./@d"s, | |
| 34 | x: ~x"./@horiz-adv-x"s | |
| 35 | ) | |
| 36 | ||
| 37 | 91 | defp octicon(name) when is_atom(name), do: octicon(Atom.to_string(name)) |
| 38 | ||
| 39 | Enum.each(octicons, fn %{name: name, d: d, x: x} -> | |
| 40 | 29 | defp octicon(unquote(name)), do: {unquote(d), unquote(x)} |
| 41 | end) | |
| 42 | ||
| 43 | doc = File.read!(@glyphicons_svg) | |
| 44 | ||
| 45 | glyphicons = | |
| 46 | SweetXml.xpath( | |
| 47 | doc, | |
| 48 | ~x"//glyph[@unicode][@d]"l, | |
| 49 | unicode: if(broken_xmerl?, do: ~x"./@unicode", else: ~x"./@unicode"s), | |
| 50 | d: ~x"./@d"s, | |
| 51 | x: ~x"./@horiz-adv-x"s | |
| 52 | ) | |
| 53 | ||
| 54 | lines = | |
| 55 | File.read!(@glyphicons_less) | |
| 56 | |> String.split("\n", trim: true) | |
| 57 | ||
| 58 | @glyphicon_less_regex ~r'\.glyphicon-([-\w]+)\s*\{ &:before \{ content: "\\([0-9a-f]{4})"; \} \}' | |
| 59 | glyphicon_names = | |
| 60 | Enum.reduce(lines, %{}, fn line, map -> | |
| 61 | case Regex.run(@glyphicon_less_regex, line) do | |
| 62 | [_, name, content] -> | |
| 63 | Map.put(map, content, name) | |
| 64 | ||
| 65 | nil -> | |
| 66 | map | |
| 67 | end | |
| 68 | end) | |
| 69 | ||
| 70 | 230 | defp glyphicon(name) when is_atom(name), do: glyphicon(Atom.to_string(name)) |
| 71 | ||
| 72 | Enum.each(glyphicons, fn %{unicode: unicode, d: d, x: x} -> | |
| 73 | unicode = if broken_xmerl?, do: IO.iodata_to_binary(Enum.reverse(unicode)), else: unicode | |
| 74 | ||
| 75 | name = | |
| 76 | case unicode do | |
| 77 | <<char::utf8>> -> | |
| 78 | codepoint = | |
| 79 | char | |
| 80 | |> Integer.to_string(16) | |
| 81 | |> String.pad_leading(4, "0") | |
| 82 | |> String.downcase() | |
| 83 | ||
| 84 | Map.get(glyphicon_names, codepoint) | |
| 85 | end | |
| 86 | ||
| 87 | if name do | |
| 88 | 115 | defp glyphicon(unquote(name)), do: {unquote(d), unquote(x)} |
| 89 | end | |
| 90 | end) | |
| 91 | ||
| 92 | 220 | def icon(type, name, opts \\ []) |
| 93 | ||
| 94 | def icon(:octicon, name, opts) do | |
| 95 | 91 | class = "octicon octicon-#{name}" |
| 96 | 91 | {d, x} = octicon(name) |
| 97 | 91 | title = if title = opts[:title], do: content_tag(:title, title), else: "" |
| 98 | ||
| 99 | 91 | opts = |
| 100 | opts | |
| 101 | |> Keyword.put_new(:"aria-hidden", "true") | |
| 102 | |> Keyword.put_new(:version, "1.1") | |
| 103 | 91 | |> Keyword.put_new(:viewBox, "0 0 #{x} 1024") |
| 104 | 22 | |> Keyword.update(:class, class, &"#{class} #{&1}") |
| 105 | |> Keyword.drop([:title]) | |
| 106 | ||
| 107 | 91 | content_tag :svg, opts do |
| 108 | content_tag :g, transform: "translate(0, 800) scale(1, -1)" do | |
| 109 | [content_tag(:path, "", d: d), title] | |
| 110 | end | |
| 111 | end | |
| 112 | end | |
| 113 | ||
| 114 | def icon(:glyphicon, name, opts) do | |
| 115 | 230 | class = "glyphicon glyphicon-#{name}" |
| 116 | 230 | {d, x} = glyphicon(name) |
| 117 | 230 | x = if x == "", do: "1200", else: x |
| 118 | 230 | title = if title = opts[:title], do: content_tag(:title, title), else: "" |
| 119 | ||
| 120 | 230 | opts = |
| 121 | opts | |
| 122 | |> Keyword.put_new(:"aria-hidden", "true") | |
| 123 | |> Keyword.put_new(:version, "1.1") | |
| 124 | 230 | |> Keyword.put_new(:viewBox, "0 0 #{x} 1200") |
| 125 | 7 | |> Keyword.update(:class, class, &"#{class} #{&1}") |
| 126 | |> Keyword.drop([:title]) | |
| 127 | ||
| 128 | 230 | content_tag :svg, opts do |
| 129 | content_tag :g, transform: "translate(0, 1200) scale(1, -1)" do | |
| 130 | [content_tag(:path, "", d: d), title] | |
| 131 | end | |
| 132 | end | |
| 133 | end | |
| 134 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.LayoutView do | |
| 1 | use HexpmWeb, :view | |
| 2 | ||
| 3 | def show_search?(assigns) do | |
| 4 | 115 | Map.get(assigns, :hide_search) != true |
| 5 | end | |
| 6 | ||
| 7 | def title(assigns) do | |
| 8 | 115 | if title = Map.get(assigns, :title) do |
| 9 | 73 | "#{title} | Hex" |
| 10 | else | |
| 11 | "Hex" | |
| 12 | end | |
| 13 | end | |
| 14 | ||
| 15 | def description(assigns) do | |
| 16 | 230 | if description = Map.get(assigns, :description) do |
| 17 | 18 | String.slice(description, 0, 160) |
| 18 | else | |
| 19 | "A package manager for the Erlang ecosystem" | |
| 20 | end | |
| 21 | end | |
| 22 | ||
| 23 | def canonical_url(assigns) do | |
| 24 | 115 | if url = Map.get(assigns, :canonical_url) do |
| 25 | 9 | tag(:link, rel: "canonical", href: url) |
| 26 | else | |
| 27 | nil | |
| 28 | end | |
| 29 | end | |
| 30 | ||
| 31 | def search(assigns) do | |
| 32 | 113 | Map.get(assigns, :search) |
| 33 | end | |
| 34 | ||
| 35 | def container_class(assigns) do | |
| 36 | 115 | Map.get(assigns, :container, "container") |
| 37 | end | |
| 38 | ||
| 39 | 115 | def og_tags(assigns) do |
| 40 | [ | |
| 41 | tag(:meta, property: "og:title", content: Map.get(assigns, :title)), | |
| 42 | tag(:meta, property: "og:type", content: "website"), | |
| 43 | tag(:meta, property: "og:url", content: Map.get(assigns, :canonical_url)), | |
| 44 | tag( | |
| 45 | :meta, | |
| 46 | property: "og:image", | |
| 47 | content: Routes.static_url(HexpmWeb.Endpoint, "/images/favicon-160.png") | |
| 48 | ), | |
| 49 | tag(:meta, property: "og:image:width", content: "160"), | |
| 50 | tag(:meta, property: "og:image:height", content: "160"), | |
| 51 | tag(:meta, property: "og:description", content: description(assigns)), | |
| 52 | tag(:meta, property: "og:site_name", content: "Hex") | |
| 53 | ] | |
| 54 | end | |
| 55 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.LoginView do | |
| 1 | use HexpmWeb, :view | |
| 2 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.OpenSearchView do | |
| 1 | use HexpmWeb, :view | |
| 2 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.PackageReportView do | |
| 1 | use HexpmWeb, :view | |
| 2 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.PackageView do | |
| 1 | use HexpmWeb, :view | |
| 2 | ||
| 3 | 1 | def show_sort_info(nil), do: show_sort_info(:name) |
| 4 | 2 | def show_sort_info(:name), do: "Sort: Name" |
| 5 | 1 | def show_sort_info(:inserted_at), do: "Sort: Recently created" |
| 6 | 1 | def show_sort_info(:updated_at), do: "Sort: Recently updated" |
| 7 | 1 | def show_sort_info(:total_downloads), do: "Sort: Total downloads" |
| 8 | 5 | def show_sort_info(:recent_downloads), do: "Sort: Recent downloads" |
| 9 | 1 | def show_sort_info(_param), do: nil |
| 10 | ||
| 11 | def downloads_for_package(package, downloads) do | |
| 12 | 14 | Map.get(downloads, package.id, %{"all" => 0, "recent" => 0}) |
| 13 | end | |
| 14 | ||
| 15 | def display_downloads(package_downloads, view) do | |
| 16 | 28 | case view do |
| 17 | :recent_downloads -> | |
| 18 | 14 | Map.get(package_downloads, "recent") |
| 19 | ||
| 20 | _ -> | |
| 21 | 14 | Map.get(package_downloads, "all") |
| 22 | end | |
| 23 | end | |
| 24 | ||
| 25 | def display_downloads_for_opposite_views(package_downloads, view) do | |
| 26 | 14 | case view do |
| 27 | :recent_downloads -> | |
| 28 | 14 | downloads = display_downloads(package_downloads, :all) || 0 |
| 29 | 14 | "total downloads: #{ViewHelpers.human_number_space(downloads)}" |
| 30 | ||
| 31 | _ -> | |
| 32 | 0 | downloads = display_downloads(package_downloads, :recent_downloads) || 0 |
| 33 | 0 | "recent downloads: #{ViewHelpers.human_number_space(downloads)}" |
| 34 | end | |
| 35 | end | |
| 36 | ||
| 37 | def display_downloads_view_title(view) do | |
| 38 | 14 | case view do |
| 39 | 14 | :recent_downloads -> "recent downloads" |
| 40 | 0 | _ -> "total downloads" |
| 41 | end | |
| 42 | end | |
| 43 | ||
| 44 | def dep_snippet(:mix, package, release) do | |
| 45 | 69 | version = snippet_version(:mix, release.version) |
| 46 | 69 | app_name = (release.meta && release.meta.app) || package.name |
| 47 | 69 | organization = snippet_organization(package.repository.name) |
| 48 | ||
| 49 | 69 | if package.name == app_name do |
| 50 | 57 | "{:#{package.name}, \"#{version}\"#{organization}}" |
| 51 | else | |
| 52 | 12 | "{#{app_name(:mix, app_name)}, \"#{version}\", hex: :#{package.name}#{organization}}" |
| 53 | end | |
| 54 | end | |
| 55 | ||
| 56 | def dep_snippet(:rebar, package, release) do | |
| 57 | 68 | version = snippet_version(:rebar, release.version) |
| 58 | 68 | app_name = (release.meta && release.meta.app) || package.name |
| 59 | ||
| 60 | 68 | if package.name == app_name do |
| 61 | 56 | "{#{package.name}, \"#{version}\"}" |
| 62 | else | |
| 63 | 12 | "{#{app_name(:rebar, app_name)}, \"#{version}\", {pkg, #{package.name}}}" |
| 64 | end | |
| 65 | end | |
| 66 | ||
| 67 | def dep_snippet(:erlang_mk, package, release) do | |
| 68 | 66 | version = snippet_version(:erlang_mk, release.version) |
| 69 | 66 | "dep_#{package.name} = hex #{version}" |
| 70 | end | |
| 71 | ||
| 72 | def snippet_version(:mix, %Version{major: 0, minor: minor, patch: patch, pre: []}) do | |
| 73 | 48 | "~> 0.#{minor}.#{patch}" |
| 74 | end | |
| 75 | ||
| 76 | def snippet_version(:mix, %Version{major: major, minor: minor, pre: []}) do | |
| 77 | 24 | "~> #{major}.#{minor}" |
| 78 | end | |
| 79 | ||
| 80 | def snippet_version(:mix, %Version{major: major, minor: minor, patch: patch, pre: pre}) do | |
| 81 | 1 | "~> #{major}.#{minor}.#{patch}#{pre_snippet(pre)}" |
| 82 | end | |
| 83 | ||
| 84 | def snippet_version(other, %Version{major: major, minor: minor, patch: patch, pre: pre}) | |
| 85 | when other in [:rebar, :erlang_mk] do | |
| 86 | 142 | "#{major}.#{minor}.#{patch}#{pre_snippet(pre)}" |
| 87 | end | |
| 88 | ||
| 89 | 55 | defp snippet_organization("hexpm"), do: "" |
| 90 | 14 | defp snippet_organization(repository), do: ", organization: #{inspect(repository)}" |
| 91 | ||
| 92 | 140 | defp pre_snippet([]), do: "" |
| 93 | ||
| 94 | defp pre_snippet(pre) do | |
| 95 | 3 | "-" <> |
| 96 | Enum.map_join(pre, ".", fn | |
| 97 | 6 | int when is_integer(int) -> Integer.to_string(int) |
| 98 | 3 | string when is_binary(string) -> string |
| 99 | end) | |
| 100 | end | |
| 101 | ||
| 102 | @elixir_atom_chars ~r"^[a-zA-Z_][a-zA-Z_0-9]*$" | |
| 103 | @erlang_atom_chars ~r"^[a-z][a-zA-Z_0-9]*$" | |
| 104 | ||
| 105 | defp app_name(:mix, name) do | |
| 106 | 12 | if Regex.match?(@elixir_atom_chars, name) do |
| 107 | 11 | ":#{name}" |
| 108 | else | |
| 109 | 1 | ":#{inspect(name)}" |
| 110 | end | |
| 111 | end | |
| 112 | ||
| 113 | defp app_name(:rebar, name) do | |
| 114 | 12 | if Regex.match?(@erlang_atom_chars, name) do |
| 115 | 11 | name |
| 116 | else | |
| 117 | 1 | inspect(String.to_charlist(name)) |
| 118 | end | |
| 119 | end | |
| 120 | ||
| 121 | def retirement_message(retirement) do | |
| 122 | 4 | reason = ReleaseRetirement.reason_text(retirement.reason) |
| 123 | ||
| 124 | 4 | head = |
| 125 | 4 | case retirement.reason do |
| 126 | 0 | "report" -> ["Marked package"] |
| 127 | 4 | _ -> ["Retired package"] |
| 128 | end | |
| 129 | ||
| 130 | 4 | body = |
| 131 | cond do | |
| 132 | 4 | reason && retirement.message -> |
| 133 | 1 | [": ", reason, " - ", retirement.message] |
| 134 | ||
| 135 | 3 | reason -> |
| 136 | [": ", reason] | |
| 137 | ||
| 138 | 2 | retirement.message -> |
| 139 | 1 | [": ", retirement.message] |
| 140 | ||
| 141 | 1 | true -> |
| 142 | [] | |
| 143 | end | |
| 144 | ||
| 145 | 4 | head ++ body |
| 146 | end | |
| 147 | ||
| 148 | def retirement_html(retirement) do | |
| 149 | 4 | reason = ReleaseRetirement.reason_text(retirement.reason) |
| 150 | ||
| 151 | 4 | msg_head = |
| 152 | 4 | case retirement.reason do |
| 153 | 0 | "report" -> [content_tag(:strong, "Marked package:")] |
| 154 | 4 | _ -> [content_tag(:strong, "Retired package:")] |
| 155 | end | |
| 156 | ||
| 157 | 4 | msg_body = |
| 158 | cond do | |
| 159 | 4 | reason && retirement.message -> |
| 160 | 1 | [" ", reason, " - ", retirement.message] |
| 161 | ||
| 162 | 3 | reason -> |
| 163 | [" ", reason] | |
| 164 | ||
| 165 | 2 | retirement.message -> |
| 166 | 1 | [" ", retirement.message] |
| 167 | ||
| 168 | 1 | true -> |
| 169 | [] | |
| 170 | end | |
| 171 | ||
| 172 | 4 | msg_head ++ msg_body |
| 173 | end | |
| 174 | ||
| 175 | def path_for_audit_logs(package, options) do | |
| 176 | 9 | if package.repository.id == 1 do |
| 177 | 7 | Routes.package_path(Endpoint, :audit_logs, package, options) |
| 178 | else | |
| 179 | 2 | Routes.package_path(Endpoint, :audit_logs, package.repository, package, options) |
| 180 | end | |
| 181 | end | |
| 182 | ||
| 183 | @doc """ | |
| 184 | This function turns an audit_log struct into a short description. | |
| 185 | ||
| 186 | Please check Hexpm.Accounts.AuditLog.extract_params/2 to see all the | |
| 187 | package related actions and their params structures. | |
| 188 | """ | |
| 189 | def humanize_audit_log_info(%{action: "docs.publish"} = audit_log) do | |
| 190 | 4 | if release_version = audit_log.params["release"]["version"] do |
| 191 | 1 | "Publish documentation for release #{release_version}" |
| 192 | else | |
| 193 | "Publish documentation" | |
| 194 | end | |
| 195 | end | |
| 196 | ||
| 197 | def humanize_audit_log_info(%{action: "docs.revert"} = audit_log) do | |
| 198 | 3 | if release_version = audit_log.params["release"]["version"] do |
| 199 | 1 | "Revert documentation for release #{release_version}" |
| 200 | else | |
| 201 | "Revert documentation" | |
| 202 | end | |
| 203 | end | |
| 204 | ||
| 205 | def humanize_audit_log_info(%{action: "owner.add"} = audit_log) do | |
| 206 | 3 | username = audit_log.params["user"]["username"] |
| 207 | 3 | level = audit_log.params["level"] |
| 208 | ||
| 209 | 3 | if username && level do |
| 210 | 1 | "Add #{username} as a level #{level} owner" |
| 211 | else | |
| 212 | "Add owner" | |
| 213 | end | |
| 214 | end | |
| 215 | ||
| 216 | def humanize_audit_log_info(%{action: "owner.transfer"} = audit_log) do | |
| 217 | 2 | if username = audit_log.params["user"]["username"] do |
| 218 | 1 | "Transfer owner to #{username}" |
| 219 | else | |
| 220 | "Transfer owner" | |
| 221 | end | |
| 222 | end | |
| 223 | ||
| 224 | def humanize_audit_log_info(%{action: "owner.remove"} = audit_log) do | |
| 225 | 3 | username = audit_log.params["user"]["username"] |
| 226 | 3 | level = audit_log.params["level"] |
| 227 | ||
| 228 | 3 | if username && level do |
| 229 | 1 | "Remove level #{level} owner #{username}" |
| 230 | else | |
| 231 | "Remove owner" | |
| 232 | end | |
| 233 | end | |
| 234 | ||
| 235 | def humanize_audit_log_info(%{action: "release.publish"} = audit_log) do | |
| 236 | 3 | if version = audit_log.params["release"]["version"] do |
| 237 | 1 | "Publish release #{version}" |
| 238 | else | |
| 239 | "Publish release" | |
| 240 | end | |
| 241 | end | |
| 242 | ||
| 243 | def humanize_audit_log_info(%{action: "release.revert"} = audit_log) do | |
| 244 | 3 | if version = audit_log.params["release"]["version"] do |
| 245 | 1 | "Revert release #{version}" |
| 246 | else | |
| 247 | "Revert release" | |
| 248 | end | |
| 249 | end | |
| 250 | ||
| 251 | def humanize_audit_log_info(%{action: "release.retire"} = audit_log) do | |
| 252 | 3 | if version = audit_log.params["release"]["version"] do |
| 253 | 1 | "Retire release #{version}" |
| 254 | else | |
| 255 | "Retire release" | |
| 256 | end | |
| 257 | end | |
| 258 | ||
| 259 | def humanize_audit_log_info(%{action: "release.unretire"} = audit_log) do | |
| 260 | 3 | if version = audit_log.params["release"]["version"] do |
| 261 | 1 | "Unretire release #{version}" |
| 262 | else | |
| 263 | "Unretire release" | |
| 264 | end | |
| 265 | end | |
| 266 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.PageView do | |
| 1 | use HexpmWeb, :view | |
| 2 | ||
| 3 | def render_package(data) do | |
| 4 | 11 | data = |
| 5 | [ | |
| 6 | downloads: nil, | |
| 7 | description: nil, | |
| 8 | inserted_at: nil, | |
| 9 | version: nil | |
| 10 | ] | |
| 11 | |> Keyword.merge(data) | |
| 12 | ||
| 13 | 11 | render("_package.html", data) |
| 14 | end | |
| 15 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.PasswordResetView do | |
| 1 | use HexpmWeb, :view | |
| 2 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.PasswordView do | |
| 1 | use HexpmWeb, :view | |
| 2 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.PolicyView do | |
| 1 | use HexpmWeb, :view | |
| 2 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.SharedView do | |
| 1 | use HexpmWeb, :view | |
| 2 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.SignupView do | |
| 1 | use HexpmWeb, :view | |
| 2 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.SitemapView do | |
| 1 | use HexpmWeb, :view | |
| 2 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.TFAAuthView do | |
| 1 | use HexpmWeb, :view | |
| 2 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.TFARecoveryView do | |
| 1 | use HexpmWeb, :view | |
| 2 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.UserView do | |
| 1 | use HexpmWeb, :view | |
| 2 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.VersionView do | |
| 1 | use HexpmWeb, :view | |
| 2 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.ViewHelpers do | |
| 1 | use Phoenix.HTML | |
| 2 | alias Hexpm.Repository.{Package, Release} | |
| 3 | alias HexpmWeb.Endpoint | |
| 4 | alias HexpmWeb.Router.Helpers, as: Routes | |
| 5 | ||
| 6 | def logged_in?(assigns) do | |
| 7 | 115 | !!assigns[:current_user] |
| 8 | end | |
| 9 | ||
| 10 | def package_name(package) do | |
| 11 | 43 | package_name(package.repository.name, package.name) |
| 12 | end | |
| 13 | ||
| 14 | def package_name("hexpm", package) do | |
| 15 | 34 | package |
| 16 | end | |
| 17 | ||
| 18 | def package_name(repository, package) do | |
| 19 | 9 | repository <> " / " <> package |
| 20 | end | |
| 21 | ||
| 22 | def path_for_package(package) do | |
| 23 | 25 | if package.repository.id == 1 do |
| 24 | 20 | Routes.package_path(Endpoint, :show, package, []) |
| 25 | else | |
| 26 | 5 | Routes.package_path(Endpoint, :show, package.repository, package, []) |
| 27 | end | |
| 28 | end | |
| 29 | ||
| 30 | def path_for_package("hexpm", package) do | |
| 31 | 0 | Routes.package_path(Endpoint, :show, package, []) |
| 32 | end | |
| 33 | ||
| 34 | def path_for_package(repository, package) do | |
| 35 | 0 | Routes.package_path(Endpoint, :show, repository, package, []) |
| 36 | end | |
| 37 | ||
| 38 | def path_for_release(package, release) do | |
| 39 | 29 | if package.repository.id == 1 do |
| 40 | 25 | Routes.package_path(Endpoint, :show, package, release, []) |
| 41 | else | |
| 42 | 4 | Routes.package_path(Endpoint, :show, package.repository, package, release, []) |
| 43 | end | |
| 44 | end | |
| 45 | ||
| 46 | def path_for_releases(package) do | |
| 47 | 0 | if package.repository.id == 1 do |
| 48 | 0 | Routes.version_path(Endpoint, :index, package, []) |
| 49 | else | |
| 50 | 0 | Routes.version_path(Endpoint, :index, package.repository, package, []) |
| 51 | end | |
| 52 | end | |
| 53 | ||
| 54 | def html_url_for_package(%Package{repository_id: 1} = package) do | |
| 55 | 23 | Routes.package_url(Endpoint, :show, package, []) |
| 56 | end | |
| 57 | ||
| 58 | def html_url_for_package(%Package{} = package) do | |
| 59 | 7 | Routes.package_url(Endpoint, :show, package.repository, package, []) |
| 60 | end | |
| 61 | ||
| 62 | def html_url_for_release(%Package{repository_id: 1} = package, release) do | |
| 63 | 26 | Routes.package_url(Endpoint, :show, package, release, []) |
| 64 | end | |
| 65 | ||
| 66 | def html_url_for_release(%Package{} = package, release) do | |
| 67 | 7 | Routes.package_url(Endpoint, :show, package.repository, package, release, []) |
| 68 | end | |
| 69 | ||
| 70 | def docs_html_url_for_package(package) do | |
| 71 | 23 | if Enum.any?(package.releases, & &1.has_docs) do |
| 72 | 17 | Hexpm.Utils.docs_html_url(package.repository, package, nil) |
| 73 | end | |
| 74 | end | |
| 75 | ||
| 76 | 27 | def docs_html_url_for_release(_package, %Release{has_docs: false}) do |
| 77 | nil | |
| 78 | end | |
| 79 | ||
| 80 | def docs_html_url_for_release(package, release) do | |
| 81 | 6 | Hexpm.Utils.docs_html_url(package.repository, package, release) |
| 82 | end | |
| 83 | ||
| 84 | def url_for_package(%Package{repository_id: 1} = package) do | |
| 85 | 53 | Routes.api_package_url(Endpoint, :show, package, []) |
| 86 | end | |
| 87 | ||
| 88 | def url_for_package(package) do | |
| 89 | 17 | Routes.api_package_url(Endpoint, :show, package.repository, package, []) |
| 90 | end | |
| 91 | ||
| 92 | def url_for_release(%Package{repository_id: 1} = package, release) do | |
| 93 | 51 | Routes.api_release_url(Endpoint, :show, package, release, []) |
| 94 | end | |
| 95 | ||
| 96 | def url_for_release(%Package{} = package, release) do | |
| 97 | 11 | Routes.api_release_url( |
| 98 | Endpoint, | |
| 99 | :show, | |
| 100 | 11 | package.repository, |
| 101 | package, | |
| 102 | 11 | to_string(release.version), |
| 103 | [] | |
| 104 | ) | |
| 105 | end | |
| 106 | ||
| 107 | def gravatar_url(nil, size) do | |
| 108 | 0 | "https://www.gravatar.com/avatar?s=#{gravatar_size(size)}&d=mm" |
| 109 | end | |
| 110 | ||
| 111 | def gravatar_url(email, size) do | |
| 112 | 96 | hash = |
| 113 | :crypto.hash(:md5, String.trim(email)) | |
| 114 | |> Base.encode16(case: :lower) | |
| 115 | ||
| 116 | 96 | "https://www.gravatar.com/avatar/#{hash}?s=#{gravatar_size(size)}&d=retro" |
| 117 | end | |
| 118 | ||
| 119 | 15 | defp gravatar_size(:large), do: 440 |
| 120 | 81 | defp gravatar_size(:small), do: 80 |
| 121 | ||
| 122 | def changeset_error(changeset) do | |
| 123 | 24 | if changeset.action do |
| 124 | 6 | content_tag :div, class: "alert alert-danger" do |
| 125 | "Oops, something went wrong! Please check the errors below." | |
| 126 | end | |
| 127 | end | |
| 128 | end | |
| 129 | ||
| 130 | def text_input(form, field, opts \\ []) do | |
| 131 | 115 | value = form.params[Atom.to_string(field)] || Map.get(form.data, field) |
| 132 | ||
| 133 | 115 | opts = |
| 134 | opts | |
| 135 | |> add_error_class(form, field) | |
| 136 | |> Keyword.put_new(:value, value) | |
| 137 | ||
| 138 | 115 | Phoenix.HTML.Form.text_input(form, field, opts) |
| 139 | end | |
| 140 | ||
| 141 | def email_input(form, field, opts \\ []) do | |
| 142 | 6 | value = form.params[Atom.to_string(field)] || Map.get(form.data, field) |
| 143 | ||
| 144 | 6 | opts = |
| 145 | opts | |
| 146 | |> add_error_class(form, field) | |
| 147 | |> Keyword.put_new(:value, value) | |
| 148 | ||
| 149 | 6 | Phoenix.HTML.Form.email_input(form, field, opts) |
| 150 | end | |
| 151 | ||
| 152 | def password_input(form, field, opts \\ []) do | |
| 153 | 18 | opts = add_error_class(opts, form, field) |
| 154 | 18 | Phoenix.HTML.Form.password_input(form, field, opts) |
| 155 | end | |
| 156 | ||
| 157 | def select(form, field, options, opts \\ []) do | |
| 158 | 13 | opts = add_error_class(opts, form, field) |
| 159 | 13 | Phoenix.HTML.Form.select(form, field, options, opts) |
| 160 | end | |
| 161 | ||
| 162 | defp add_error_class(opts, form, field) do | |
| 163 | 152 | error? = Keyword.has_key?(form.errors, field) |
| 164 | 152 | error_class = if error?, do: "form-input-error", else: "" |
| 165 | 152 | class = "form-control #{error_class} #{opts[:class]}" |
| 166 | ||
| 167 | 152 | Keyword.put(opts, :class, class) |
| 168 | end | |
| 169 | ||
| 170 | def error_tag(form, field) do | |
| 171 | 153 | if error = form.errors[field] do |
| 172 | 6 | content_tag(:span, translate_error(error), class: "form-error") |
| 173 | end | |
| 174 | end | |
| 175 | ||
| 176 | defp translate_error({msg, opts}) do | |
| 177 | 6 | Enum.reduce(opts, msg, fn {key, value}, msg -> |
| 178 | 5 | String.replace(msg, "%{#{key}}", to_string(value)) |
| 179 | end) | |
| 180 | end | |
| 181 | ||
| 182 | def paginate(page, count, opts) do | |
| 183 | 11 | per_page = opts[:items_per_page] |
| 184 | # Needs to be odd number | |
| 185 | 11 | max_links = opts[:page_links] |
| 186 | ||
| 187 | 11 | all_pages = div(count - 1, per_page) + 1 |
| 188 | 11 | middle_links = div(max_links, 2) + 1 |
| 189 | ||
| 190 | 11 | page_links = |
| 191 | cond do | |
| 192 | page < middle_links -> | |
| 193 | 11 | Enum.take(1..max_links, all_pages) |
| 194 | ||
| 195 | 0 | page > all_pages - middle_links -> |
| 196 | 0 | start = |
| 197 | 0 | if all_pages > middle_links + 1 do |
| 198 | 0 | all_pages - (middle_links + 1) |
| 199 | else | |
| 200 | 1 | |
| 201 | end | |
| 202 | ||
| 203 | 0 | Enum.to_list(start..all_pages) |
| 204 | ||
| 205 | 0 | true -> |
| 206 | 0 | Enum.to_list((page - 2)..(page + 2)) |
| 207 | end | |
| 208 | ||
| 209 | 11 | %{prev: page != 1, next: page != all_pages, page_links: page_links} |
| 210 | end | |
| 211 | ||
| 212 | def params(list) do | |
| 213 | 30 | Enum.filter(list, fn {_, v} -> present?(v) end) |
| 214 | end | |
| 215 | ||
| 216 | 0 | def present?(""), do: false |
| 217 | 50 | def present?(nil), do: false |
| 218 | 40 | def present?(_), do: true |
| 219 | ||
| 220 | def text_length(text, length) when byte_size(text) > length do | |
| 221 | 0 | :binary.part(text, 0, length - 3) <> "..." |
| 222 | end | |
| 223 | ||
| 224 | def text_length(text, _length) do | |
| 225 | 46 | text |
| 226 | end | |
| 227 | ||
| 228 | 44 | def human_number_space(0, _max), do: "0" |
| 229 | ||
| 230 | def human_number_space(int, max) when is_integer(int) do | |
| 231 | 19 | unit = |
| 232 | cond do | |
| 233 | 3 | int >= 1_000_000_000 -> {"B", 9} |
| 234 | 16 | int >= 1_000_000 -> {"M", 6} |
| 235 | 11 | int >= 1_000 -> {"K", 3} |
| 236 | 4 | true -> {"", 1} |
| 237 | end | |
| 238 | ||
| 239 | 19 | do_human_number(int, max, trunc(:math.log10(int)) + 1, unit) |
| 240 | end | |
| 241 | ||
| 242 | def human_number_space(number) do | |
| 243 | number | |
| 244 | 104 | |> to_string() |
| 245 | |> String.to_charlist() | |
| 246 | |> Enum.reverse() | |
| 247 | |> Enum.chunk_every(3) | |
| 248 | |> Enum.intersperse(?\s) | |
| 249 | |> List.flatten() | |
| 250 | |> Enum.reverse() | |
| 251 | 104 | |> :erlang.list_to_binary() |
| 252 | end | |
| 253 | ||
| 254 | defp do_human_number(int, max, digits, _unit) when is_integer(int) and digits <= max do | |
| 255 | 9 | human_number_space(int) |
| 256 | end | |
| 257 | ||
| 258 | defp do_human_number(int, max, digits, {unit, mag}) when is_integer(int) and digits > max do | |
| 259 | 10 | shifted = int / :math.pow(10, mag) |
| 260 | 10 | len = trunc(:math.log10(shifted)) + 2 |
| 261 | 10 | float = Float.round(shifted, max - len) |
| 262 | ||
| 263 | 10 | case Float.ratio(float) do |
| 264 | 5 | {_, 1} -> human_number_space(trunc(float)) <> unit |
| 265 | 5 | {_, _} -> to_string(float) <> unit |
| 266 | end | |
| 267 | end | |
| 268 | ||
| 269 | def human_relative_time_from_now(datetime) do | |
| 270 | 26 | ts = NaiveDateTime.to_erl(datetime) |> :calendar.datetime_to_gregorian_seconds() |
| 271 | 26 | diff = :calendar.datetime_to_gregorian_seconds(:calendar.universal_time()) - ts |
| 272 | 26 | rel = rel_from_now(:calendar.seconds_to_daystime(diff)) |
| 273 | ||
| 274 | 26 | content_tag(:span, rel, title: pretty_date(datetime)) |
| 275 | end | |
| 276 | ||
| 277 | 15 | defp rel_from_now({0, {0, 0, sec}}) when sec < 30, do: "about now" |
| 278 | 0 | defp rel_from_now({0, {0, min, _}}) when min < 2, do: "1 minute ago" |
| 279 | 0 | defp rel_from_now({0, {0, min, _}}), do: "#{min} minutes ago" |
| 280 | 0 | defp rel_from_now({0, {1, _, _}}), do: "1 hour ago" |
| 281 | 0 | defp rel_from_now({0, {hour, _, _}}) when hour < 24, do: "#{hour} hours ago" |
| 282 | 0 | defp rel_from_now({1, {_, _, _}}), do: "1 day ago" |
| 283 | 0 | defp rel_from_now({day, {_, _, _}}) when day < 0, do: "about now" |
| 284 | 11 | defp rel_from_now({day, {_, _, _}}), do: "#{day} days ago" |
| 285 | ||
| 286 | def pretty_datetime(datetime) do | |
| 287 | 40 | Calendar.strftime(datetime, "%b %d, %Y, %H:%M") |
| 288 | end | |
| 289 | ||
| 290 | def pretty_date(date) do | |
| 291 | 31 | Calendar.strftime(date, "%B %d, %Y") |
| 292 | end | |
| 293 | ||
| 294 | def pretty_date(date, :short) do | |
| 295 | 40 | Calendar.strftime(date, "%b %d, %Y") |
| 296 | end | |
| 297 | ||
| 298 | 0 | def if_value(arg, nil, _fun), do: arg |
| 299 | 0 | def if_value(arg, false, _fun), do: arg |
| 300 | 0 | def if_value(arg, _true, fun), do: fun.(arg) |
| 301 | ||
| 302 | def safe_join(enum, separator, fun \\ & &1) do | |
| 303 | 0 | Enum.map_join(enum, separator, &safe_to_string(fun.(&1))) |
| 304 | 9 | |> raw() |
| 305 | end | |
| 306 | ||
| 307 | 71 | def include_if_loaded(output, key, struct, view, name \\ "show.json", assigns \\ %{}) |
| 308 | ||
| 309 | def include_if_loaded(output, _key, %Ecto.Association.NotLoaded{}, _view, _name, _assigns) do | |
| 310 | 48 | output |
| 311 | end | |
| 312 | ||
| 313 | def include_if_loaded(output, _key, nil, _view, _name, _assigns) do | |
| 314 | 9 | output |
| 315 | end | |
| 316 | ||
| 317 | def include_if_loaded(output, key, struct, fun, _name, _assigns) when is_function(fun, 1) do | |
| 318 | 18 | Map.put(output, key, fun.(struct)) |
| 319 | end | |
| 320 | ||
| 321 | def include_if_loaded(output, key, structs, view, name, assigns) when is_list(structs) do | |
| 322 | 72 | Map.put(output, key, Phoenix.View.render_many(structs, view, name, assigns)) |
| 323 | end | |
| 324 | ||
| 325 | def include_if_loaded(output, key, struct, view, name, assigns) do | |
| 326 | 0 | Map.put(output, key, Phoenix.View.render_one(struct, view, name, assigns)) |
| 327 | end | |
| 328 | ||
| 329 | def auth_qr_code_svg(user) do | |
| 330 | 1 | "otpauth://totp/hex.pm:#{user.username}?issuer=hex.pm&secret=#{user.tfa.secret}" |
| 331 | |> EQRCode.encode() | |
| 332 | 1 | |> EQRCode.svg(width: 250) |
| 333 | end | |
| 334 | ||
| 335 | # assumes positive values only, and graph dimensions of 800 x 200 | |
| 336 | def time_series_graph(points) do | |
| 337 | 9 | max = |
| 338 | Enum.max(points ++ [5]) | |
| 339 | |> rounded_max() | |
| 340 | ||
| 341 | 9 | y_axis_labels = y_axis_labels(0, max) |
| 342 | ||
| 343 | 9 | calculated_points = |
| 344 | points | |
| 345 | 279 | |> Enum.map(fn p -> points_to_graph(max, p) end) |
| 346 | |> Enum.zip(x_axis_points(length(points))) | |
| 347 | ||
| 348 | 9 | polyline_points = to_polyline_points(calculated_points) |
| 349 | 9 | polyline_fill = to_polyline_fill(calculated_points) |
| 350 | ||
| 351 | 9 | {y_axis_labels, polyline_points, polyline_fill} |
| 352 | end | |
| 353 | ||
| 354 | defp points_to_graph(max, data) do | |
| 355 | 279 | px_per_point = 200 / max |
| 356 | 279 | 198 - (data |> Kernel.*(px_per_point) |> Float.round(3)) |
| 357 | end | |
| 358 | ||
| 359 | defp x_axis_points(total_points) do | |
| 360 | # width / points captured | |
| 361 | 9 | px_per_point = Float.round(800 / total_points, 2) |
| 362 | 9 | Enum.map(0..total_points, &Kernel.*(&1, px_per_point)) |
| 363 | end | |
| 364 | ||
| 365 | defp to_polyline_points(list) do | |
| 366 | 9 | Enum.reduce(list, "", fn {y, x}, acc -> acc <> "#{x}, #{y} " end) |
| 367 | end | |
| 368 | ||
| 369 | defp to_polyline_fill(list) do | |
| 370 | 9 | top = Enum.reduce(list, "", fn {y, x}, acc -> acc <> "#{x}, #{y} " end) |
| 371 | 9 | {_last_y, last_x} = List.last(list) |
| 372 | 9 | fill = "#{last_x}, 200 0, 200" |
| 373 | 9 | top <> fill |
| 374 | end | |
| 375 | ||
| 376 | defp y_axis_labels(min, max) do | |
| 377 | 9 | div = (rounded_max(max) - min) / 5 |
| 378 | ||
| 379 | [ | |
| 380 | min, | |
| 381 | round(div), | |
| 382 | round(div * 2), | |
| 383 | round(div * 3), | |
| 384 | round(div * 4) | |
| 385 | ] | |
| 386 | end | |
| 387 | ||
| 388 | defp rounded_max(max) do | |
| 389 | 18 | case max do |
| 390 | 0 | max when max > 1_000_000 -> max |> Kernel./(1_000_000) |> ceil |> Kernel.*(1_000_000) |
| 391 | 0 | max when max > 100_000 -> max |> Kernel./(100_000) |> ceil |> Kernel.*(100_000) |
| 392 | 0 | max when max > 10_000 -> max |> Kernel./(10_000) |> ceil |> Kernel.*(10_000) |
| 393 | 0 | max when max > 1_000 -> max |> Kernel./(1_000) |> ceil |> Kernel.*(1_000) |
| 394 | 0 | max when max > 100 -> 1_000 |
| 395 | 18 | _ -> 100 |
| 396 | end | |
| 397 | end | |
| 398 | end | |
| 399 | ||
| 400 | defimpl Phoenix.HTML.Safe, for: Version do | |
| 401 | 55 | def to_iodata(version), do: String.Chars.Version.to_string(version) |
| 402 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb do | |
| 1 | @moduledoc """ | |
| 2 | A module that keeps using definitions for controllers, | |
| 3 | views and so on. | |
| 4 | ||
| 5 | This can be used in your application as: | |
| 6 | ||
| 7 | use HexpmWeb, :controller | |
| 8 | use HexpmWeb, :view | |
| 9 | ||
| 10 | The definitions below will be executed for every view, | |
| 11 | controller, etc, so keep them short and clean, focused | |
| 12 | on imports, uses and aliases. | |
| 13 | ||
| 14 | Do NOT define functions inside the quoted expressions | |
| 15 | below. | |
| 16 | """ | |
| 17 | ||
| 18 | def controller() do | |
| 19 | quote do | |
| 20 | use Phoenix.Controller, namespace: HexpmWeb | |
| 21 | ||
| 22 | import Ecto | |
| 23 | import Ecto.Query, only: [from: 1, from: 2] | |
| 24 | ||
| 25 | import HexpmWeb.{ControllerHelpers, AuthHelpers} | |
| 26 | ||
| 27 | alias HexpmWeb.{Endpoint, Router} | |
| 28 | alias HexpmWeb.Router.Helpers, as: Routes | |
| 29 | ||
| 30 | use Hexpm.Shared | |
| 31 | end | |
| 32 | end | |
| 33 | ||
| 34 | def view() do | |
| 35 | quote do | |
| 36 | use Phoenix.View, | |
| 37 | root: "lib/hexpm_web/templates", | |
| 38 | namespace: HexpmWeb | |
| 39 | ||
| 40 | use Phoenix.HTML | |
| 41 | ||
| 42 | # Import convenience functions from controllers | |
| 43 | import Phoenix.Controller, only: [get_csrf_token: 0, get_flash: 2, view_module: 1] | |
| 44 | ||
| 45 | # Use all HTML functionality (forms, tags, etc) | |
| 46 | import Phoenix.HTML.Form, | |
| 47 | except: [ | |
| 48 | text_input: 2, | |
| 49 | text_input: 3, | |
| 50 | email_input: 2, | |
| 51 | email_input: 3, | |
| 52 | password_input: 2, | |
| 53 | password_input: 3, | |
| 54 | select: 3, | |
| 55 | select: 4 | |
| 56 | ] | |
| 57 | ||
| 58 | import HexpmWeb.ViewIcons | |
| 59 | ||
| 60 | alias HexpmWeb.ViewHelpers | |
| 61 | alias HexpmWeb.{Endpoint, Router} | |
| 62 | alias HexpmWeb.Router.Helpers, as: Routes | |
| 63 | ||
| 64 | use Hexpm.Shared | |
| 65 | end | |
| 66 | end | |
| 67 | ||
| 68 | def router() do | |
| 69 | quote do | |
| 70 | use Phoenix.Router | |
| 71 | import HexpmWeb.Plugs | |
| 72 | ||
| 73 | alias HexpmWeb.{Endpoint, Router} | |
| 74 | alias HexpmWeb.Router.Helpers, as: Routes | |
| 75 | end | |
| 76 | end | |
| 77 | ||
| 78 | @doc """ | |
| 79 | When used, dispatch to the appropriate controller/view/etc. | |
| 80 | """ | |
| 81 | defmacro __using__(which) when is_atom(which) do | |
| 82 | 0 | apply(__MODULE__, which, []) |
| 83 | end | |
| 84 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.Case do | |
| 1 | def key_for(user_or_organization) do | |
| 2 | 183 | {:ok, %{key: key}} = |
| 3 | Hexpm.Accounts.Keys.create( | |
| 4 | user_or_organization, | |
| 5 | %{name: "any_key_name"}, | |
| 6 | audit: nil | |
| 7 | ) | |
| 8 | ||
| 9 | 183 | key.user_secret |
| 10 | end | |
| 11 | ||
| 12 | def read_fixture(path) do | |
| 13 | Path.join([__DIR__, "..", "fixtures", path]) | |
| 14 | 4 | |> File.read!() |
| 15 | end | |
| 16 | ||
| 17 | def audit_data(user) do | |
| 18 | 62 | {user, "TEST", "127.0.0.1"} |
| 19 | end | |
| 20 | ||
| 21 | def default_meta(name, version) do | |
| 22 | 6 | %{ |
| 23 | "name" => name, | |
| 24 | "description" => "description", | |
| 25 | "licenses" => [], | |
| 26 | "version" => version, | |
| 27 | "requirements" => [], | |
| 28 | "app" => name, | |
| 29 | "build_tools" => ["mix"], | |
| 30 | "files" => ["mix.exs"] | |
| 31 | } | |
| 32 | end | |
| 33 | ||
| 34 | def default_requirement(name, requirement) do | |
| 35 | 1 | %{"name" => name, "app" => name, "requirement" => requirement, "optional" => false} |
| 36 | end | |
| 37 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule HexpmWeb.ConnCase do | |
| 1 | @moduledoc """ | |
| 2 | This module defines the test case to be used by | |
| 3 | tests that require setting up a connection. | |
| 4 | ||
| 5 | Such tests rely on `Phoenix.ConnTest` and also | |
| 6 | imports other functionality to make it easier | |
| 7 | to build and query models. | |
| 8 | ||
| 9 | Finally, if the test case interacts with the database, | |
| 10 | it cannot be async. For this reason, every test runs | |
| 11 | inside a transaction which is reset at the beginning | |
| 12 | of the test unless the test case is marked as async. | |
| 13 | """ | |
| 14 | ||
| 15 | use ExUnit.CaseTemplate | |
| 16 | ||
| 17 | 49 | using do |
| 18 | quote do | |
| 19 | # Import conveniences for testing with connections | |
| 20 | alias Hexpm.{Fake, Repo} | |
| 21 | alias HexpmWeb.Router.Helpers, as: Routes | |
| 22 | ||
| 23 | import Ecto | |
| 24 | import Ecto.Query, only: [from: 2] | |
| 25 | import Plug.Conn | |
| 26 | import Phoenix.ConnTest | |
| 27 | import Hexpm.{Case, Factory, TestHelpers} | |
| 28 | import unquote(__MODULE__) | |
| 29 | ||
| 30 | # The default endpoint for testing | |
| 31 | @endpoint HexpmWeb.Endpoint | |
| 32 | end | |
| 33 | end | |
| 34 | ||
| 35 | setup do | |
| 36 | 507 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Hexpm.RepoBase) |
| 37 | 507 | Bamboo.SentEmail.reset() |
| 38 | :ok | |
| 39 | end | |
| 40 | ||
| 41 | def test_login(conn, user) do | |
| 42 | 126 | Plug.Test.init_test_session(conn, %{"user_id" => user.id}) |
| 43 | end | |
| 44 | ||
| 45 | def last_session() do | |
| 46 | import Ecto.Query | |
| 47 | ||
| 48 | from(s in Hexpm.Accounts.Session, order_by: [desc: s.id], limit: 1) | |
| 49 | 6 | |> Hexpm.Repo.one() |
| 50 | end | |
| 51 | ||
| 52 | def json_post(conn, path, params) do | |
| 53 | conn | |
| 54 | |> Plug.Conn.put_req_header("content-type", "application/json") | |
| 55 | 3 | |> Phoenix.ConnTest.dispatch(HexpmWeb.Endpoint, :post, path, Jason.encode!(params)) |
| 56 | end | |
| 57 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.DataCase do | |
| 1 | @moduledoc """ | |
| 2 | This module defines the test case to be used by | |
| 3 | model tests. | |
| 4 | ||
| 5 | You may define functions here to be used as helpers in | |
| 6 | your model tests. See `errors_on/2`'s definition as reference. | |
| 7 | ||
| 8 | Finally, if the test case interacts with the database, | |
| 9 | it cannot be async. For this reason, every test runs | |
| 10 | inside a transaction which is reset at the beginning | |
| 11 | of the test unless the test case is marked as async. | |
| 12 | """ | |
| 13 | ||
| 14 | use ExUnit.CaseTemplate | |
| 15 | ||
| 16 | 19 | using do |
| 17 | quote do | |
| 18 | import Ecto | |
| 19 | import Ecto.Query, only: [from: 2] | |
| 20 | import Hexpm.{Case, DataCase, Factory, TestHelpers} | |
| 21 | ||
| 22 | alias Hexpm.{Fake, Repo} | |
| 23 | end | |
| 24 | end | |
| 25 | ||
| 26 | setup do | |
| 27 | 154 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Hexpm.RepoBase) |
| 28 | ||
| 29 | :ok | |
| 30 | end | |
| 31 | ||
| 32 | @doc """ | |
| 33 | Helper for returning list of errors in model when passed certain data. | |
| 34 | ||
| 35 | ## Examples | |
| 36 | ||
| 37 | Given a User model that lists `:name` as a required field and validates | |
| 38 | `:password` to be safe, it would return: | |
| 39 | ||
| 40 | iex> errors_on(%User{}, %{password: "password"}) | |
| 41 | [password: "is unsafe", name: "is blank"] | |
| 42 | ||
| 43 | You could then write your assertion like: | |
| 44 | ||
| 45 | assert {:password, "is unsafe"} in errors_on(%User{}, %{password: "password"}) | |
| 46 | ||
| 47 | You can also create the changeset manually and retrieve the errors | |
| 48 | field directly: | |
| 49 | ||
| 50 | iex> changeset = User.changeset(%User{}, password: "password") | |
| 51 | iex> {:password, "is unsafe"} in changeset.errors | |
| 52 | true | |
| 53 | """ | |
| 54 | def errors_on(model, data) do | |
| 55 | 0 | model.__struct__.changeset(model, data).errors |
| 56 | end | |
| 57 | ||
| 58 | def errors_on(%Ecto.Changeset{} = changeset) do | |
| 59 | 23 | HexpmWeb.ControllerHelpers.translate_errors(changeset) |
| 60 | end | |
| 61 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Hexpm.TestHelpers do | |
| 1 | @tmp Application.compile_env(:hexpm, :tmp_dir) | |
| 2 | ||
| 3 | def create_tar(meta, files \\ [{"mix.exs", "mix.exs"}]) do | |
| 4 | 49 | meta = |
| 5 | meta | |
| 6 | |> Map.put_new(:app, meta[:name]) | |
| 7 | |> Map.put_new(:build_tools, ["mix"]) | |
| 8 | |> Map.put_new(:licenses, ["Apache-2.0"]) | |
| 9 | |> Map.put_new(:requirements, %{}) | |
| 10 | 49 | |> Map.put_new(:files, Enum.map(files, &elem(&1, 0))) |
| 11 | ||
| 12 | 49 | contents_path = Path.join(@tmp, "#{meta[:name]}-#{meta[:version]}-contents.tar.gz") |
| 13 | 49 | files = Enum.map(files, fn {name, bin} -> {String.to_charlist(name), bin} end) |
| 14 | 49 | :ok = :erl_tar.create(contents_path, files, [:compressed]) |
| 15 | 49 | contents = File.read!(contents_path) |
| 16 | ||
| 17 | 49 | meta_string = HexpmWeb.ConsultFormat.encode(meta) |
| 18 | 49 | blob = "3" <> meta_string <> contents |
| 19 | 49 | checksum = :crypto.hash(:sha256, blob) |> Base.encode16() |
| 20 | ||
| 21 | 49 | files = [ |
| 22 | {'VERSION', "3"}, | |
| 23 | {'CHECKSUM', checksum}, | |
| 24 | {'metadata.config', meta_string}, | |
| 25 | {'contents.tar.gz', contents} | |
| 26 | ] | |
| 27 | ||
| 28 | 49 | path = Path.join(@tmp, "#{meta[:name]}-#{meta[:version]}.tar") |
| 29 | 49 | :ok = :erl_tar.create(path, files) |
| 30 | ||
| 31 | 49 | File.read!(path) |
| 32 | end | |
| 33 | ||
| 34 | def rel_meta(params) do | |
| 35 | 46 | params = params(params) |
| 36 | ||
| 37 | 46 | meta = |
| 38 | params | |
| 39 | |> Map.put_new("build_tools", ["mix"]) | |
| 40 | |> Map.put_new("files", ["mix.exs"]) | |
| 41 | ||
| 42 | params | |
| 43 | |> Map.put("meta", meta) | |
| 44 | 46 | |> Map.update("requirements", [], &requirements_meta/1) |
| 45 | end | |
| 46 | ||
| 47 | def pkg_meta(meta) do | |
| 48 | 11 | params = params(meta) |
| 49 | 11 | meta = Map.put_new(params, "licenses", ["Apache-2.0"]) |
| 50 | 11 | Map.put(params, "meta", meta) |
| 51 | end | |
| 52 | ||
| 53 | def params(params) when is_map(params) do | |
| 54 | 77 | Enum.into(params, %{}, fn |
| 55 | 3 | {binary, value} when is_binary(binary) -> {binary, params(value)} |
| 56 | 209 | {atom, value} when is_atom(atom) -> {Atom.to_string(atom), params(value)} |
| 57 | end) | |
| 58 | end | |
| 59 | ||
| 60 | 16 | def params(params) when is_list(params), do: Enum.map(params, ¶ms/1) |
| 61 | 198 | def params(other), do: other |
| 62 | ||
| 63 | def mock_pwned() do | |
| 64 | 38 | Mox.stub(Hexpm.Pwned.Mock, :password_breached?, fn _password -> false end) |
| 65 | end | |
| 66 | ||
| 67 | defp requirements_meta(list) do | |
| 68 | 14 | Enum.map(list, fn req -> |
| 69 | req | |
| 70 | |> Map.put_new("repository", "hexpm") | |
| 71 | |> Map.put_new("optional", false) | |
| 72 | 16 | |> Map.put_new("app", req["name"]) |
| 73 | end) | |
| 74 | end | |
| 75 | end |